[WEB-2494] feat: text color and highlight options for all editors (#5653)
* feat: add text color and highlight options to pages * style: rich text editor floating toolbar * chore: remove unused function * refactor: slash command components * chore: move default text and background options to the top * fix: sections filtering logic
This commit is contained in:
parent
5afc576dec
commit
c3c1ea727d
33 changed files with 1166 additions and 542 deletions
|
|
@ -42,13 +42,15 @@
|
||||||
"@tiptap/extension-blockquote": "^2.1.13",
|
"@tiptap/extension-blockquote": "^2.1.13",
|
||||||
"@tiptap/extension-character-count": "^2.6.5",
|
"@tiptap/extension-character-count": "^2.6.5",
|
||||||
"@tiptap/extension-collaboration": "^2.3.2",
|
"@tiptap/extension-collaboration": "^2.3.2",
|
||||||
|
"@tiptap/extension-color": "^2.7.1",
|
||||||
|
"@tiptap/extension-highlight": "^2.7.1",
|
||||||
"@tiptap/extension-image": "^2.1.13",
|
"@tiptap/extension-image": "^2.1.13",
|
||||||
"@tiptap/extension-list-item": "^2.1.13",
|
"@tiptap/extension-list-item": "^2.1.13",
|
||||||
"@tiptap/extension-mention": "^2.1.13",
|
"@tiptap/extension-mention": "^2.1.13",
|
||||||
"@tiptap/extension-placeholder": "^2.3.0",
|
"@tiptap/extension-placeholder": "^2.3.0",
|
||||||
"@tiptap/extension-task-item": "^2.1.13",
|
"@tiptap/extension-task-item": "^2.1.13",
|
||||||
"@tiptap/extension-task-list": "^2.1.13",
|
"@tiptap/extension-task-list": "^2.1.13",
|
||||||
"@tiptap/extension-text-style": "^2.1.13",
|
"@tiptap/extension-text-style": "^2.7.1",
|
||||||
"@tiptap/extension-underline": "^2.1.13",
|
"@tiptap/extension-underline": "^2.1.13",
|
||||||
"@tiptap/pm": "^2.1.13",
|
"@tiptap/pm": "^2.1.13",
|
||||||
"@tiptap/react": "^2.1.13",
|
"@tiptap/react": "^2.1.13",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||||
import { Extensions } from "@tiptap/core";
|
import { Extensions } from "@tiptap/core";
|
||||||
import { SlashCommand } from "@/extensions";
|
import { SlashCommands } from "@/extensions";
|
||||||
// plane editor types
|
// plane editor types
|
||||||
import { TIssueEmbedConfig } from "@/plane-editor/types";
|
import { TIssueEmbedConfig } from "@/plane-editor/types";
|
||||||
// types
|
// types
|
||||||
|
|
@ -14,7 +14,7 @@ type Props = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
|
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
|
||||||
const extensions: Extensions = [SlashCommand()];
|
const extensions: Extensions = [SlashCommands()];
|
||||||
|
|
||||||
return extensions;
|
return extensions;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { forwardRef, useCallback } from "react";
|
||||||
import { EditorWrapper } from "@/components/editors";
|
import { EditorWrapper } from "@/components/editors";
|
||||||
import { EditorBubbleMenu } from "@/components/menus";
|
import { EditorBubbleMenu } from "@/components/menus";
|
||||||
// extensions
|
// extensions
|
||||||
import { SideMenuExtension, SlashCommand } from "@/extensions";
|
import { SideMenuExtension, SlashCommands } from "@/extensions";
|
||||||
// types
|
// types
|
||||||
import { EditorRefApi, IRichTextEditor } from "@/types";
|
import { EditorRefApi, IRichTextEditor } from "@/types";
|
||||||
|
|
||||||
|
|
@ -11,7 +11,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
|
||||||
const { dragDropEnabled } = props;
|
const { dragDropEnabled } = props;
|
||||||
|
|
||||||
const getExtensions = useCallback(() => {
|
const getExtensions = useCallback(() => {
|
||||||
const extensions = [SlashCommand()];
|
const extensions = [SlashCommands()];
|
||||||
|
|
||||||
extensions.push(
|
extensions.push(
|
||||||
SideMenuExtension({
|
SideMenuExtension({
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { Dispatch, FC, SetStateAction } from "react";
|
||||||
|
import { Editor } from "@tiptap/react";
|
||||||
|
import { ALargeSmall, Ban } from "lucide-react";
|
||||||
|
// constants
|
||||||
|
import { COLORS_LIST } from "@/constants/common";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "@/helpers/common";
|
||||||
|
import { BackgroundColorItem, TextColorItem } from "../menu-items";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
editor: Editor;
|
||||||
|
isOpen: boolean;
|
||||||
|
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BubbleMenuColorSelector: FC<Props> = (props) => {
|
||||||
|
const { editor, isOpen, setIsOpen } = props;
|
||||||
|
|
||||||
|
const activeTextColor = COLORS_LIST.find((c) => editor.getAttributes("textStyle").color === c.textColor);
|
||||||
|
const activeBackgroundColor = COLORS_LIST.find((c) =>
|
||||||
|
editor.isActive("highlight", {
|
||||||
|
color: c.backgroundColor,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-full">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 h-full whitespace-nowrap px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<span>Color</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex-shrink-0 size-6 grid place-items-center rounded border-[0.5px] border-custom-border-300",
|
||||||
|
{
|
||||||
|
"bg-custom-background-100": !activeBackgroundColor,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
activeBackgroundColor
|
||||||
|
? {
|
||||||
|
backgroundColor: activeBackgroundColor.backgroundColor,
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ALargeSmall
|
||||||
|
className={cn("size-3.5", {
|
||||||
|
"text-custom-text-100": !activeTextColor,
|
||||||
|
})}
|
||||||
|
style={
|
||||||
|
activeTextColor
|
||||||
|
? {
|
||||||
|
color: activeTextColor.textColor,
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<section className="fixed top-full z-[99999] mt-1 rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 p-2 space-y-2 shadow-custom-shadow-rg animate-in fade-in slide-in-from-top-1">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs text-custom-text-300 font-semibold">Text colors</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{COLORS_LIST.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color.textColor}
|
||||||
|
type="button"
|
||||||
|
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
|
||||||
|
style={{
|
||||||
|
backgroundColor: color.textColor,
|
||||||
|
}}
|
||||||
|
onClick={() => TextColorItem(editor).command(color.textColor)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
|
||||||
|
onClick={() => TextColorItem(editor).command(undefined)}
|
||||||
|
>
|
||||||
|
<Ban className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs text-custom-text-300 font-semibold">Background colors</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{COLORS_LIST.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color.backgroundColor}
|
||||||
|
type="button"
|
||||||
|
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
|
||||||
|
style={{
|
||||||
|
backgroundColor: color.backgroundColor,
|
||||||
|
}}
|
||||||
|
onClick={() => BackgroundColorItem(editor).command(color.backgroundColor)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
|
||||||
|
onClick={() => BackgroundColorItem(editor).command(undefined)}
|
||||||
|
>
|
||||||
|
<Ban className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
export * from "./color-selector";
|
||||||
export * from "./link-selector";
|
export * from "./link-selector";
|
||||||
export * from "./node-selector";
|
export * from "./node-selector";
|
||||||
export * from "./root";
|
export * from "./root";
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react";
|
import { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react";
|
||||||
import { Editor } from "@tiptap/core";
|
import { Editor } from "@tiptap/core";
|
||||||
import { Check, Trash } from "lucide-react";
|
import { Check, Link, Trash } from "lucide-react";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn, isValidHttpUrl } from "@/helpers/common";
|
import { cn, isValidHttpUrl } from "@/helpers/common";
|
||||||
import { setLinkEditor, unsetLinkEditor } from "@/helpers/editor-commands";
|
import { setLinkEditor, unsetLinkEditor } from "@/helpers/editor-commands";
|
||||||
|
|
@ -11,7 +11,9 @@ type Props = {
|
||||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BubbleMenuLinkSelector: FC<Props> = ({ editor, isOpen, setIsOpen }) => {
|
export const BubbleMenuLinkSelector: FC<Props> = (props) => {
|
||||||
|
const { editor, isOpen, setIsOpen } = props;
|
||||||
|
// refs
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const onLinkSubmit = useCallback(() => {
|
const onLinkSubmit = useCallback(() => {
|
||||||
|
|
@ -28,26 +30,23 @@ export const BubbleMenuLinkSelector: FC<Props> = ({ editor, isOpen, setIsOpen })
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative h-full">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
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",
|
"h-full flex items-center gap-1 px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded transition-colors",
|
||||||
{ "bg-custom-background-100": isOpen }
|
{
|
||||||
|
"bg-custom-background-80": isOpen,
|
||||||
|
"text-custom-text-100": editor.isActive("link"),
|
||||||
|
}
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
setIsOpen(!isOpen);
|
setIsOpen(!isOpen);
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p className="text-base">↗</p>
|
<span>Link</span>
|
||||||
<p
|
<Link className="flex-shrink-0 size-3" />
|
||||||
className={cn("underline underline-offset-4", {
|
|
||||||
"text-custom-text-100": editor.isActive("link"),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
Link
|
|
||||||
</p>
|
|
||||||
</button>
|
</button>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import {
|
||||||
HeadingFourItem,
|
HeadingFourItem,
|
||||||
HeadingFiveItem,
|
HeadingFiveItem,
|
||||||
HeadingSixItem,
|
HeadingSixItem,
|
||||||
BubbleMenuItem,
|
EditorMenuItem,
|
||||||
} from "@/components/menus";
|
} from "@/components/menus";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "@/helpers/common";
|
import { cn } from "@/helpers/common";
|
||||||
|
|
@ -26,8 +26,10 @@ type Props = {
|
||||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BubbleMenuNodeSelector: FC<Props> = ({ editor, isOpen, setIsOpen }) => {
|
export const BubbleMenuNodeSelector: FC<Props> = (props) => {
|
||||||
const items: BubbleMenuItem[] = [
|
const { editor, isOpen, setIsOpen } = props;
|
||||||
|
|
||||||
|
const items: EditorMenuItem[] = [
|
||||||
TextItem(editor),
|
TextItem(editor),
|
||||||
HeadingOneItem(editor),
|
HeadingOneItem(editor),
|
||||||
HeadingTwoItem(editor),
|
HeadingTwoItem(editor),
|
||||||
|
|
@ -42,7 +44,7 @@ export const BubbleMenuNodeSelector: FC<Props> = ({ editor, isOpen, setIsOpen })
|
||||||
CodeItem(editor),
|
CodeItem(editor),
|
||||||
];
|
];
|
||||||
|
|
||||||
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
|
const activeItem = items.filter((item) => item.isActive("")).pop() ?? {
|
||||||
name: "Multiple",
|
name: "Multiple",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -54,12 +56,11 @@ export const BubbleMenuNodeSelector: FC<Props> = ({ editor, isOpen, setIsOpen })
|
||||||
setIsOpen(!isOpen);
|
setIsOpen(!isOpen);
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
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"
|
className="flex items-center gap-1 h-full whitespace-nowrap px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded transition-colors"
|
||||||
>
|
>
|
||||||
<span>{activeItem?.name}</span>
|
<span>{activeItem?.name}</span>
|
||||||
<ChevronDown className="h-4 w-4" />
|
<ChevronDown className="flex-shrink-0 size-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<section className="fixed top-full z-[99999] mt-1 flex w-48 flex-col overflow-hidden rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg animate-in fade-in slide-in-from-top-1">
|
<section className="fixed top-full z-[99999] mt-1 flex w-48 flex-col overflow-hidden rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg animate-in fade-in slide-in-from-top-1">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import { FC, useEffect, useState } from "react";
|
import { FC, useEffect, useState } from "react";
|
||||||
import { BubbleMenu, BubbleMenuProps, isNodeSelection } from "@tiptap/react";
|
import { BubbleMenu, BubbleMenuProps, isNodeSelection } from "@tiptap/react";
|
||||||
import { LucideIcon } from "lucide-react";
|
|
||||||
// components
|
// components
|
||||||
import {
|
import {
|
||||||
BoldItem,
|
BoldItem,
|
||||||
|
BubbleMenuColorSelector,
|
||||||
BubbleMenuLinkSelector,
|
BubbleMenuLinkSelector,
|
||||||
BubbleMenuNodeSelector,
|
BubbleMenuNodeSelector,
|
||||||
CodeItem,
|
CodeItem,
|
||||||
|
EditorMenuItem,
|
||||||
ItalicItem,
|
ItalicItem,
|
||||||
StrikeThroughItem,
|
StrikeThroughItem,
|
||||||
UnderLineItem,
|
UnderLineItem,
|
||||||
|
|
@ -16,34 +17,23 @@ import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-sele
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "@/helpers/common";
|
import { cn } from "@/helpers/common";
|
||||||
|
|
||||||
export interface BubbleMenuItem {
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
isActive: () => boolean;
|
|
||||||
command: () => void;
|
|
||||||
icon: LucideIcon;
|
|
||||||
}
|
|
||||||
|
|
||||||
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
|
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
|
||||||
|
|
||||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
||||||
const items: BubbleMenuItem[] = [
|
// states
|
||||||
...(props.editor.isActive("code")
|
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
||||||
? []
|
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
||||||
: [
|
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||||
BoldItem(props.editor),
|
const [isSelecting, setIsSelecting] = useState(false);
|
||||||
ItalicItem(props.editor),
|
|
||||||
UnderLineItem(props.editor),
|
const items: EditorMenuItem[] = props.editor.isActive("code")
|
||||||
StrikeThroughItem(props.editor),
|
? [CodeItem(props.editor)]
|
||||||
]),
|
: [BoldItem(props.editor), ItalicItem(props.editor), UnderLineItem(props.editor), StrikeThroughItem(props.editor)];
|
||||||
CodeItem(props.editor),
|
|
||||||
];
|
|
||||||
|
|
||||||
const bubbleMenuProps: EditorBubbleMenuProps = {
|
const bubbleMenuProps: EditorBubbleMenuProps = {
|
||||||
...props,
|
...props,
|
||||||
shouldShow: ({ state, editor }) => {
|
shouldShow: ({ state, editor }) => {
|
||||||
const { selection } = state;
|
const { selection } = state;
|
||||||
|
|
||||||
const { empty } = selection;
|
const { empty } = selection;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
@ -63,15 +53,11 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
||||||
onHidden: () => {
|
onHidden: () => {
|
||||||
setIsNodeSelectorOpen(false);
|
setIsNodeSelectorOpen(false);
|
||||||
setIsLinkSelectorOpen(false);
|
setIsLinkSelectorOpen(false);
|
||||||
|
setIsColorSelectorOpen(false);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
|
||||||
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
|
||||||
|
|
||||||
const [isSelecting, setIsSelecting] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleMouseDown() {
|
function handleMouseDown() {
|
||||||
function handleMouseMove() {
|
function handleMouseMove() {
|
||||||
|
|
@ -102,51 +88,66 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
||||||
return (
|
return (
|
||||||
<BubbleMenu
|
<BubbleMenu
|
||||||
{...bubbleMenuProps}
|
{...bubbleMenuProps}
|
||||||
className="flex w-fit divide-x divide-custom-border-300 rounded border border-custom-border-300 bg-custom-background-100 shadow-xl"
|
className="flex py-2 divide-x divide-custom-border-200 rounded-lg border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-rg"
|
||||||
>
|
>
|
||||||
{isSelecting ? null : (
|
{!isSelecting && (
|
||||||
<>
|
<>
|
||||||
|
<div className="px-2">
|
||||||
{!props.editor.isActive("table") && (
|
{!props.editor.isActive("table") && (
|
||||||
<BubbleMenuNodeSelector
|
<BubbleMenuNodeSelector
|
||||||
editor={props.editor!}
|
editor={props.editor!}
|
||||||
isOpen={isNodeSelectorOpen}
|
isOpen={isNodeSelectorOpen}
|
||||||
setIsOpen={() => {
|
setIsOpen={() => {
|
||||||
setIsNodeSelectorOpen(!isNodeSelectorOpen);
|
setIsNodeSelectorOpen((prev) => !prev);
|
||||||
setIsLinkSelectorOpen(false);
|
setIsLinkSelectorOpen(false);
|
||||||
|
setIsColorSelectorOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="px-2">
|
||||||
{!props.editor.isActive("code") && (
|
{!props.editor.isActive("code") && (
|
||||||
<BubbleMenuLinkSelector
|
<BubbleMenuLinkSelector
|
||||||
editor={props.editor}
|
editor={props.editor}
|
||||||
isOpen={isLinkSelectorOpen}
|
isOpen={isLinkSelectorOpen}
|
||||||
setIsOpen={() => {
|
setIsOpen={() => {
|
||||||
setIsLinkSelectorOpen(!isLinkSelectorOpen);
|
setIsLinkSelectorOpen((prev) => !prev);
|
||||||
setIsNodeSelectorOpen(false);
|
setIsNodeSelectorOpen(false);
|
||||||
|
setIsColorSelectorOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex">
|
</div>
|
||||||
|
<div className="px-2">
|
||||||
|
{!props.editor.isActive("code") && (
|
||||||
|
<BubbleMenuColorSelector
|
||||||
|
editor={props.editor}
|
||||||
|
isOpen={isColorSelectorOpen}
|
||||||
|
setIsOpen={() => {
|
||||||
|
setIsColorSelectorOpen((prev) => !prev);
|
||||||
|
setIsNodeSelectorOpen(false);
|
||||||
|
setIsLinkSelectorOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-0.5 px-2">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.name}
|
key={item.key}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
item.command();
|
item.command();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-2 text-custom-text-300 transition-colors hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5",
|
"size-7 grid place-items-center rounded text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 transition-colors",
|
||||||
{
|
{
|
||||||
"bg-custom-primary-100/5 text-custom-text-100": item.isActive(),
|
"bg-custom-background-80 text-custom-text-100": item.isActive(""),
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon
|
<item.icon className="size-4" />
|
||||||
className={cn("h-4 w-4", {
|
|
||||||
"text-custom-text-100": item.isActive(),
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,14 @@ import {
|
||||||
Heading6,
|
Heading6,
|
||||||
CaseSensitive,
|
CaseSensitive,
|
||||||
LucideIcon,
|
LucideIcon,
|
||||||
|
Palette,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
// helpers
|
// helpers
|
||||||
import {
|
import {
|
||||||
insertImage,
|
insertImage,
|
||||||
insertTableCommand,
|
insertTableCommand,
|
||||||
setText,
|
setText,
|
||||||
|
toggleBackgroundColor,
|
||||||
toggleBlockquote,
|
toggleBlockquote,
|
||||||
toggleBold,
|
toggleBold,
|
||||||
toggleBulletList,
|
toggleBulletList,
|
||||||
|
|
@ -40,18 +42,26 @@ import {
|
||||||
toggleOrderedList,
|
toggleOrderedList,
|
||||||
toggleStrike,
|
toggleStrike,
|
||||||
toggleTaskList,
|
toggleTaskList,
|
||||||
|
toggleTextColor,
|
||||||
toggleUnderline,
|
toggleUnderline,
|
||||||
} from "@/helpers/editor-commands";
|
} from "@/helpers/editor-commands";
|
||||||
// types
|
// types
|
||||||
import { TEditorCommands } from "@/types";
|
import { TColorEditorCommands, TNonColorEditorCommands } from "@/types";
|
||||||
|
|
||||||
export interface EditorMenuItem {
|
export type EditorMenuItem = {
|
||||||
key: TEditorCommands;
|
|
||||||
name: string;
|
name: string;
|
||||||
isActive: () => boolean;
|
command: (...args: any) => void;
|
||||||
command: () => void;
|
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
key: TNonColorEditorCommands;
|
||||||
|
isActive: () => boolean;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
key: TColorEditorCommands;
|
||||||
|
isActive: (color: string | undefined) => boolean;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export const TextItem = (editor: Editor): EditorMenuItem => ({
|
export const TextItem = (editor: Editor): EditorMenuItem => ({
|
||||||
key: "text",
|
key: "text",
|
||||||
|
|
@ -198,10 +208,25 @@ export const ImageItem = (editor: Editor) =>
|
||||||
icon: ImageIcon,
|
icon: ImageIcon,
|
||||||
}) as const;
|
}) as const;
|
||||||
|
|
||||||
export function getEditorMenuItems(editor: Editor | null) {
|
export const TextColorItem = (editor: Editor): EditorMenuItem => ({
|
||||||
if (!editor) {
|
key: "text-color",
|
||||||
return [];
|
name: "Color",
|
||||||
}
|
isActive: (color) => editor.getAttributes("textStyle").color === color,
|
||||||
|
command: (color: string) => toggleTextColor(color, editor),
|
||||||
|
icon: Palette,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BackgroundColorItem = (editor: Editor): EditorMenuItem => ({
|
||||||
|
key: "background-color",
|
||||||
|
name: "Background color",
|
||||||
|
isActive: (color) => editor.isActive("highlight", { color }),
|
||||||
|
command: (color: string) => toggleBackgroundColor(color, editor),
|
||||||
|
icon: Palette,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem[] => {
|
||||||
|
if (!editor) return [];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
TextItem(editor),
|
TextItem(editor),
|
||||||
HeadingOneItem(editor),
|
HeadingOneItem(editor),
|
||||||
|
|
@ -221,5 +246,7 @@ export function getEditorMenuItems(editor: Editor | null) {
|
||||||
QuoteItem(editor),
|
QuoteItem(editor),
|
||||||
TableItem(editor),
|
TableItem(editor),
|
||||||
ImageItem(editor),
|
ImageItem(editor),
|
||||||
|
TextColorItem(editor),
|
||||||
|
BackgroundColorItem(editor),
|
||||||
];
|
];
|
||||||
}
|
};
|
||||||
|
|
|
||||||
51
packages/editor/src/core/constants/common.ts
Normal file
51
packages/editor/src/core/constants/common.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
export const COLORS_LIST: {
|
||||||
|
backgroundColor: string;
|
||||||
|
textColor: string;
|
||||||
|
label: string;
|
||||||
|
}[] = [
|
||||||
|
// {
|
||||||
|
// backgroundColor: "#1c202426",
|
||||||
|
// textColor: "#1c2024",
|
||||||
|
// label: "Black",
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
backgroundColor: "#5c5e6326",
|
||||||
|
textColor: "#5c5e63",
|
||||||
|
label: "Gray",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
backgroundColor: "#ff5b5926",
|
||||||
|
textColor: "#ff5b59",
|
||||||
|
label: "Peach",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
backgroundColor: "#f6538526",
|
||||||
|
textColor: "#f65385",
|
||||||
|
label: "Pink",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
backgroundColor: "#fd903826",
|
||||||
|
textColor: "#fd9038",
|
||||||
|
label: "Orange",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
backgroundColor: "#0fc27b26",
|
||||||
|
textColor: "#0fc27b",
|
||||||
|
label: "Green",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
backgroundColor: "#17bee926",
|
||||||
|
textColor: "#17bee9",
|
||||||
|
label: "Light blue",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
backgroundColor: "#266df026",
|
||||||
|
textColor: "#266df0",
|
||||||
|
label: "Dark blue",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
backgroundColor: "#9162f926",
|
||||||
|
textColor: "#9162f9",
|
||||||
|
label: "Purple",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { Color } from "@tiptap/extension-color";
|
||||||
|
import Highlight from "@tiptap/extension-highlight";
|
||||||
import TaskItem from "@tiptap/extension-task-item";
|
import TaskItem from "@tiptap/extension-task-item";
|
||||||
import TaskList from "@tiptap/extension-task-list";
|
import TaskList from "@tiptap/extension-task-list";
|
||||||
import TextStyle from "@tiptap/extension-text-style";
|
import TextStyle from "@tiptap/extension-text-style";
|
||||||
|
|
@ -83,6 +85,10 @@ export const CoreEditorExtensionsWithoutProps = [
|
||||||
TableCell,
|
TableCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
CustomMentionWithoutProps(),
|
CustomMentionWithoutProps(),
|
||||||
|
Color,
|
||||||
|
Highlight.configure({
|
||||||
|
multicolor: true,
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DocumentEditorExtensionsWithoutProps = [IssueWidgetWithoutProps()];
|
export const DocumentEditorExtensionsWithoutProps = [IssueWidgetWithoutProps()];
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import CharacterCount from "@tiptap/extension-character-count";
|
import CharacterCount from "@tiptap/extension-character-count";
|
||||||
|
import { Color } from "@tiptap/extension-color";
|
||||||
|
import Highlight from "@tiptap/extension-highlight";
|
||||||
import Placeholder from "@tiptap/extension-placeholder";
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
import TaskItem from "@tiptap/extension-task-item";
|
import TaskItem from "@tiptap/extension-task-item";
|
||||||
import TaskList from "@tiptap/extension-task-list";
|
import TaskList from "@tiptap/extension-task-list";
|
||||||
|
|
@ -166,4 +168,8 @@ export const CoreEditorExtensions = ({
|
||||||
includeChildren: true,
|
includeChildren: true,
|
||||||
}),
|
}),
|
||||||
CharacterCount,
|
CharacterCount,
|
||||||
|
Color,
|
||||||
|
Highlight.configure({
|
||||||
|
multicolor: true,
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ export * from "./custom-list-keymap";
|
||||||
export * from "./image";
|
export * from "./image";
|
||||||
export * from "./issue-embed";
|
export * from "./issue-embed";
|
||||||
export * from "./mentions";
|
export * from "./mentions";
|
||||||
|
export * from "./slash-commands";
|
||||||
export * from "./table";
|
export * from "./table";
|
||||||
export * from "./typography";
|
export * from "./typography";
|
||||||
export * from "./core-without-props";
|
export * from "./core-without-props";
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import CharacterCount from "@tiptap/extension-character-count";
|
import CharacterCount from "@tiptap/extension-character-count";
|
||||||
|
import { Color } from "@tiptap/extension-color";
|
||||||
|
import Highlight from "@tiptap/extension-highlight";
|
||||||
import TaskItem from "@tiptap/extension-task-item";
|
import TaskItem from "@tiptap/extension-task-item";
|
||||||
import TaskList from "@tiptap/extension-task-list";
|
import TaskList from "@tiptap/extension-task-list";
|
||||||
import TextStyle from "@tiptap/extension-text-style";
|
import TextStyle from "@tiptap/extension-text-style";
|
||||||
|
|
@ -109,5 +111,9 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
||||||
readonly: true,
|
readonly: true,
|
||||||
}),
|
}),
|
||||||
CharacterCount,
|
CharacterCount,
|
||||||
|
Color,
|
||||||
|
Highlight.configure({
|
||||||
|
multicolor: true,
|
||||||
|
}),
|
||||||
HeadingListExtension,
|
HeadingListExtension,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,422 +0,0 @@
|
||||||
import { useState, useEffect, useCallback, ReactNode, useRef, useLayoutEffect } from "react";
|
|
||||||
import { Editor, Range, Extension } from "@tiptap/core";
|
|
||||||
import { ReactRenderer } from "@tiptap/react";
|
|
||||||
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
|
|
||||||
import tippy from "tippy.js";
|
|
||||||
import {
|
|
||||||
CaseSensitive,
|
|
||||||
Code2,
|
|
||||||
Heading1,
|
|
||||||
Heading2,
|
|
||||||
Heading3,
|
|
||||||
Heading4,
|
|
||||||
Heading5,
|
|
||||||
Heading6,
|
|
||||||
ImageIcon,
|
|
||||||
List,
|
|
||||||
ListOrdered,
|
|
||||||
ListTodo,
|
|
||||||
MinusSquare,
|
|
||||||
Quote,
|
|
||||||
Table,
|
|
||||||
} from "lucide-react";
|
|
||||||
// helpers
|
|
||||||
import { cn } from "@/helpers/common";
|
|
||||||
import {
|
|
||||||
insertTableCommand,
|
|
||||||
toggleBlockquote,
|
|
||||||
toggleBulletList,
|
|
||||||
toggleOrderedList,
|
|
||||||
toggleTaskList,
|
|
||||||
toggleHeadingOne,
|
|
||||||
toggleHeadingTwo,
|
|
||||||
toggleHeadingThree,
|
|
||||||
toggleHeadingFour,
|
|
||||||
toggleHeadingFive,
|
|
||||||
toggleHeadingSix,
|
|
||||||
insertImage,
|
|
||||||
} from "@/helpers/editor-commands";
|
|
||||||
// types
|
|
||||||
import { CommandProps, ISlashCommandItem } from "@/types";
|
|
||||||
|
|
||||||
interface CommandItemProps {
|
|
||||||
key: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
icon: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SlashCommandOptions = {
|
|
||||||
suggestion: Omit<SuggestionOptions, "editor">;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Command = Extension.create<SlashCommandOptions>({
|
|
||||||
name: "slash-command",
|
|
||||||
addOptions() {
|
|
||||||
return {
|
|
||||||
suggestion: {
|
|
||||||
char: "/",
|
|
||||||
command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
|
|
||||||
props.command({ editor, range });
|
|
||||||
},
|
|
||||||
allow({ editor }: { editor: Editor }) {
|
|
||||||
const { selection } = editor.state;
|
|
||||||
|
|
||||||
const parentNode = selection.$from.node(selection.$from.depth);
|
|
||||||
const blockType = parentNode.type.name;
|
|
||||||
|
|
||||||
if (blockType === "codeBlock") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (editor.isActive("table")) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
addProseMirrorPlugins() {
|
|
||||||
return [
|
|
||||||
Suggestion({
|
|
||||||
editor: this.editor,
|
|
||||||
...this.options.suggestion,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const getSuggestionItems =
|
|
||||||
(additionalOptions?: Array<ISlashCommandItem>) =>
|
|
||||||
({ query }: { query: string }) => {
|
|
||||||
let slashCommands: ISlashCommandItem[] = [
|
|
||||||
{
|
|
||||||
key: "text",
|
|
||||||
title: "Text",
|
|
||||||
description: "Just start typing with plain text.",
|
|
||||||
searchTerms: ["p", "paragraph"],
|
|
||||||
icon: <CaseSensitive className="size-3.5" />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
if (range) {
|
|
||||||
editor.chain().focus().deleteRange(range).clearNodes().run();
|
|
||||||
}
|
|
||||||
editor.chain().focus().clearNodes().run();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "h1",
|
|
||||||
title: "Heading 1",
|
|
||||||
description: "Big section heading.",
|
|
||||||
searchTerms: ["title", "big", "large"],
|
|
||||||
icon: <Heading1 className="size-3.5" />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
toggleHeadingOne(editor, range);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "h2",
|
|
||||||
title: "Heading 2",
|
|
||||||
description: "Medium section heading.",
|
|
||||||
searchTerms: ["subtitle", "medium"],
|
|
||||||
icon: <Heading2 className="size-3.5" />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
toggleHeadingTwo(editor, range);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "h3",
|
|
||||||
title: "Heading 3",
|
|
||||||
description: "Small section heading.",
|
|
||||||
searchTerms: ["subtitle", "small"],
|
|
||||||
icon: <Heading3 className="size-3.5" />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
toggleHeadingThree(editor, range);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "h4",
|
|
||||||
title: "Heading 4",
|
|
||||||
description: "Small section heading.",
|
|
||||||
searchTerms: ["subtitle", "small"],
|
|
||||||
icon: <Heading4 className="size-3.5" />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
toggleHeadingFour(editor, range);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "h5",
|
|
||||||
title: "Heading 5",
|
|
||||||
description: "Small section heading.",
|
|
||||||
searchTerms: ["subtitle", "small"],
|
|
||||||
icon: <Heading5 className="size-3.5" />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
toggleHeadingFive(editor, range);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "h6",
|
|
||||||
title: "Heading 6",
|
|
||||||
description: "Small section heading.",
|
|
||||||
searchTerms: ["subtitle", "small"],
|
|
||||||
icon: <Heading6 className="size-3.5" />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
toggleHeadingSix(editor, range);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "to-do-list",
|
|
||||||
title: "To do",
|
|
||||||
description: "Track tasks with a to-do list.",
|
|
||||||
searchTerms: ["todo", "task", "list", "check", "checkbox"],
|
|
||||||
icon: <ListTodo className="size-3.5" />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
toggleTaskList(editor, range);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "bulleted-list",
|
|
||||||
title: "Bullet list",
|
|
||||||
description: "Create a simple bullet list.",
|
|
||||||
searchTerms: ["unordered", "point"],
|
|
||||||
icon: <List className="size-3.5" />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
toggleBulletList(editor, range);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "numbered-list",
|
|
||||||
title: "Numbered list",
|
|
||||||
description: "Create a list with numbering.",
|
|
||||||
searchTerms: ["ordered"],
|
|
||||||
icon: <ListOrdered className="size-3.5" />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
toggleOrderedList(editor, range);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "table",
|
|
||||||
title: "Table",
|
|
||||||
description: "Create a table",
|
|
||||||
searchTerms: ["table", "cell", "db", "data", "tabular"],
|
|
||||||
icon: <Table className="size-3.5" />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
insertTableCommand(editor, range);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "quote",
|
|
||||||
title: "Quote",
|
|
||||||
description: "Capture a quote.",
|
|
||||||
searchTerms: ["blockquote"],
|
|
||||||
icon: <Quote className="size-3.5" />,
|
|
||||||
command: ({ editor, range }: CommandProps) => toggleBlockquote(editor, range),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "code",
|
|
||||||
title: "Code",
|
|
||||||
description: "Capture a code snippet.",
|
|
||||||
searchTerms: ["codeblock"],
|
|
||||||
icon: <Code2 className="size-3.5" />,
|
|
||||||
command: ({ editor, range }: CommandProps) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "image",
|
|
||||||
title: "Image",
|
|
||||||
icon: <ImageIcon className="size-3.5" />,
|
|
||||||
description: "Insert an image",
|
|
||||||
searchTerms: ["img", "photo", "picture", "media", "upload"],
|
|
||||||
command: ({ editor, range }: CommandProps) => insertImage({ editor, event: "insert", range }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "divider",
|
|
||||||
title: "Divider",
|
|
||||||
description: "Visually divide blocks.",
|
|
||||||
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
|
|
||||||
icon: <MinusSquare className="size-3.5" />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (additionalOptions) {
|
|
||||||
additionalOptions.map((item) => {
|
|
||||||
slashCommands.push(item);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
slashCommands = slashCommands.filter((item) => {
|
|
||||||
if (typeof query === "string" && query.length > 0) {
|
|
||||||
const search = query.toLowerCase();
|
|
||||||
return (
|
|
||||||
item.title.toLowerCase().includes(search) ||
|
|
||||||
item.description.toLowerCase().includes(search) ||
|
|
||||||
(item.searchTerms && item.searchTerms.some((term: string) => term.includes(search)))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
return slashCommands;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
|
|
||||||
const containerHeight = container.offsetHeight;
|
|
||||||
const itemHeight = item ? item.offsetHeight : 0;
|
|
||||||
|
|
||||||
const top = item.offsetTop;
|
|
||||||
const bottom = top + itemHeight;
|
|
||||||
|
|
||||||
if (top < container.scrollTop) {
|
|
||||||
container.scrollTop -= container.scrollTop - top + 5;
|
|
||||||
} else if (bottom > containerHeight + container.scrollTop) {
|
|
||||||
container.scrollTop += bottom - containerHeight - container.scrollTop + 5;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const CommandList = ({ items, command }: { items: CommandItemProps[]; command: any; editor: any; range: any }) => {
|
|
||||||
// states
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
||||||
// refs
|
|
||||||
const commandListContainer = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const selectItem = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
const item = items[index];
|
|
||||||
if (item) command(item);
|
|
||||||
},
|
|
||||||
[command, items]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
|
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (navigationKeys.includes(e.key)) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (e.key === "ArrowUp") {
|
|
||||||
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (e.key === "ArrowDown") {
|
|
||||||
setSelectedIndex((selectedIndex + 1) % items.length);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
selectItem(selectedIndex);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", onKeyDown);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("keydown", onKeyDown);
|
|
||||||
};
|
|
||||||
}, [items, selectedIndex, setSelectedIndex, selectItem]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSelectedIndex(0);
|
|
||||||
}, [items]);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const container = commandListContainer?.current;
|
|
||||||
|
|
||||||
const item = container?.children[selectedIndex] as HTMLElement;
|
|
||||||
|
|
||||||
if (item && container) updateScrollView(container, item);
|
|
||||||
}, [selectedIndex]);
|
|
||||||
|
|
||||||
if (items.length <= 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
id="slash-command"
|
|
||||||
ref={commandListContainer}
|
|
||||||
className="z-10 max-h-80 min-w-[12rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg"
|
|
||||||
>
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<button
|
|
||||||
key={item.key}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 w-full rounded px-1 py-1.5 text-sm text-left truncate text-custom-text-200 hover:bg-custom-background-80",
|
|
||||||
{
|
|
||||||
"bg-custom-background-80": index === selectedIndex,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
selectItem(index);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="grid place-items-center flex-shrink-0">{item.icon}</span>
|
|
||||||
<p className="flex-grow truncate">{item.title}</p>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface CommandListInstance {
|
|
||||||
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderItems = () => {
|
|
||||||
let component: ReactRenderer<CommandListInstance, typeof CommandList> | null = null;
|
|
||||||
let popup: any | null = null;
|
|
||||||
return {
|
|
||||||
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
|
|
||||||
component = new ReactRenderer(CommandList, {
|
|
||||||
props,
|
|
||||||
editor: props.editor,
|
|
||||||
});
|
|
||||||
|
|
||||||
const tippyContainer =
|
|
||||||
document.querySelector(".active-editor") ?? document.querySelector('[id^="editor-container"]');
|
|
||||||
|
|
||||||
// @ts-expect-error Tippy overloads are messed up
|
|
||||||
popup = tippy("body", {
|
|
||||||
getReferenceClientRect: props.clientRect,
|
|
||||||
appendTo: tippyContainer,
|
|
||||||
content: component.element,
|
|
||||||
showOnCreate: true,
|
|
||||||
interactive: true,
|
|
||||||
trigger: "manual",
|
|
||||||
placement: "bottom-start",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
|
|
||||||
component?.updateProps(props);
|
|
||||||
|
|
||||||
popup &&
|
|
||||||
popup[0].setProps({
|
|
||||||
getReferenceClientRect: props.clientRect,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
|
||||||
if (props.event.key === "Escape") {
|
|
||||||
popup?.[0].hide();
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (component?.ref?.onKeyDown(props)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
onExit: () => {
|
|
||||||
popup?.[0].destroy();
|
|
||||||
component?.destroy();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SlashCommand = (additionalOptions?: Array<ISlashCommandItem>) =>
|
|
||||||
Command.configure({
|
|
||||||
suggestion: {
|
|
||||||
items: getSuggestionItems(additionalOptions),
|
|
||||||
render: renderItems,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -0,0 +1,294 @@
|
||||||
|
import {
|
||||||
|
ALargeSmall,
|
||||||
|
CaseSensitive,
|
||||||
|
Code2,
|
||||||
|
Heading1,
|
||||||
|
Heading2,
|
||||||
|
Heading3,
|
||||||
|
Heading4,
|
||||||
|
Heading5,
|
||||||
|
Heading6,
|
||||||
|
ImageIcon,
|
||||||
|
List,
|
||||||
|
ListOrdered,
|
||||||
|
ListTodo,
|
||||||
|
MinusSquare,
|
||||||
|
Quote,
|
||||||
|
Table,
|
||||||
|
} from "lucide-react";
|
||||||
|
// constants
|
||||||
|
import { COLORS_LIST } from "@/constants/common";
|
||||||
|
// helpers
|
||||||
|
import {
|
||||||
|
insertTableCommand,
|
||||||
|
toggleBlockquote,
|
||||||
|
toggleBulletList,
|
||||||
|
toggleOrderedList,
|
||||||
|
toggleTaskList,
|
||||||
|
toggleHeadingOne,
|
||||||
|
toggleHeadingTwo,
|
||||||
|
toggleHeadingThree,
|
||||||
|
toggleHeadingFour,
|
||||||
|
toggleHeadingFive,
|
||||||
|
toggleHeadingSix,
|
||||||
|
toggleTextColor,
|
||||||
|
toggleBackgroundColor,
|
||||||
|
insertImage,
|
||||||
|
} from "@/helpers/editor-commands";
|
||||||
|
// types
|
||||||
|
import { CommandProps, ISlashCommandItem } from "@/types";
|
||||||
|
|
||||||
|
export type TSlashCommandSection = {
|
||||||
|
key: string;
|
||||||
|
title?: string;
|
||||||
|
items: ISlashCommandItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const SLASH_COMMAND_SECTIONS: TSlashCommandSection[] = [
|
||||||
|
{
|
||||||
|
key: "general",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
commandKey: "text",
|
||||||
|
key: "text",
|
||||||
|
title: "Text",
|
||||||
|
description: "Just start typing with plain text.",
|
||||||
|
searchTerms: ["p", "paragraph"],
|
||||||
|
icon: <CaseSensitive className="size-3.5" />,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
if (range) {
|
||||||
|
editor.chain().focus().deleteRange(range).clearNodes().run();
|
||||||
|
}
|
||||||
|
editor.chain().focus().clearNodes().run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
commandKey: "h1",
|
||||||
|
key: "h1",
|
||||||
|
title: "Heading 1",
|
||||||
|
description: "Big section heading.",
|
||||||
|
searchTerms: ["title", "big", "large"],
|
||||||
|
icon: <Heading1 className="size-3.5" />,
|
||||||
|
command: ({ editor, range }) => toggleHeadingOne(editor, range),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
commandKey: "h2",
|
||||||
|
key: "h2",
|
||||||
|
title: "Heading 2",
|
||||||
|
description: "Medium section heading.",
|
||||||
|
searchTerms: ["subtitle", "medium"],
|
||||||
|
icon: <Heading2 className="size-3.5" />,
|
||||||
|
command: ({ editor, range }) => toggleHeadingTwo(editor, range),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
commandKey: "h3",
|
||||||
|
key: "h3",
|
||||||
|
title: "Heading 3",
|
||||||
|
description: "Small section heading.",
|
||||||
|
searchTerms: ["subtitle", "small"],
|
||||||
|
icon: <Heading3 className="size-3.5" />,
|
||||||
|
command: ({ editor, range }) => toggleHeadingThree(editor, range),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
commandKey: "h4",
|
||||||
|
key: "h4",
|
||||||
|
title: "Heading 4",
|
||||||
|
description: "Small section heading.",
|
||||||
|
searchTerms: ["subtitle", "small"],
|
||||||
|
icon: <Heading4 className="size-3.5" />,
|
||||||
|
command: ({ editor, range }) => toggleHeadingFour(editor, range),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
commandKey: "h5",
|
||||||
|
key: "h5",
|
||||||
|
title: "Heading 5",
|
||||||
|
description: "Small section heading.",
|
||||||
|
searchTerms: ["subtitle", "small"],
|
||||||
|
icon: <Heading5 className="size-3.5" />,
|
||||||
|
command: ({ editor, range }) => toggleHeadingFive(editor, range),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
commandKey: "h6",
|
||||||
|
key: "h6",
|
||||||
|
title: "Heading 6",
|
||||||
|
description: "Small section heading.",
|
||||||
|
searchTerms: ["subtitle", "small"],
|
||||||
|
icon: <Heading6 className="size-3.5" />,
|
||||||
|
command: ({ editor, range }) => toggleHeadingSix(editor, range),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
commandKey: "to-do-list",
|
||||||
|
key: "to-do-list",
|
||||||
|
title: "To do",
|
||||||
|
description: "Track tasks with a to-do list.",
|
||||||
|
searchTerms: ["todo", "task", "list", "check", "checkbox"],
|
||||||
|
icon: <ListTodo className="size-3.5" />,
|
||||||
|
command: ({ editor, range }) => toggleTaskList(editor, range),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
commandKey: "bulleted-list",
|
||||||
|
key: "bulleted-list",
|
||||||
|
title: "Bullet list",
|
||||||
|
description: "Create a simple bullet list.",
|
||||||
|
searchTerms: ["unordered", "point"],
|
||||||
|
icon: <List className="size-3.5" />,
|
||||||
|
command: ({ editor, range }) => toggleBulletList(editor, range),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
commandKey: "numbered-list",
|
||||||
|
key: "numbered-list",
|
||||||
|
title: "Numbered list",
|
||||||
|
description: "Create a list with numbering.",
|
||||||
|
searchTerms: ["ordered"],
|
||||||
|
icon: <ListOrdered className="size-3.5" />,
|
||||||
|
command: ({ editor, range }) => toggleOrderedList(editor, range),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
commandKey: "table",
|
||||||
|
key: "table",
|
||||||
|
title: "Table",
|
||||||
|
description: "Create a table",
|
||||||
|
searchTerms: ["table", "cell", "db", "data", "tabular"],
|
||||||
|
icon: <Table className="size-3.5" />,
|
||||||
|
command: ({ editor, range }) => insertTableCommand(editor, range),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
commandKey: "quote",
|
||||||
|
key: "quote",
|
||||||
|
title: "Quote",
|
||||||
|
description: "Capture a quote.",
|
||||||
|
searchTerms: ["blockquote"],
|
||||||
|
icon: <Quote className="size-3.5" />,
|
||||||
|
command: ({ editor, range }) => toggleBlockquote(editor, range),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
commandKey: "code",
|
||||||
|
key: "code",
|
||||||
|
title: "Code",
|
||||||
|
description: "Capture a code snippet.",
|
||||||
|
searchTerms: ["codeblock"],
|
||||||
|
icon: <Code2 className="size-3.5" />,
|
||||||
|
command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
commandKey: "image",
|
||||||
|
key: "image",
|
||||||
|
title: "Image",
|
||||||
|
icon: <ImageIcon className="size-3.5" />,
|
||||||
|
description: "Insert an image",
|
||||||
|
searchTerms: ["img", "photo", "picture", "media", "upload"],
|
||||||
|
command: ({ editor, range }: CommandProps) => insertImage({ editor, event: "insert", range }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
commandKey: "divider",
|
||||||
|
key: "divider",
|
||||||
|
title: "Divider",
|
||||||
|
description: "Visually divide blocks.",
|
||||||
|
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
|
||||||
|
icon: <MinusSquare className="size-3.5" />,
|
||||||
|
command: ({ editor, range }) => editor.chain().focus().deleteRange(range).setHorizontalRule().run(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "text-color",
|
||||||
|
title: "Colors",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
commandKey: "text-color",
|
||||||
|
key: "text-color-default",
|
||||||
|
title: "Default",
|
||||||
|
description: "Change text color",
|
||||||
|
searchTerms: ["color", "text", "default"],
|
||||||
|
icon: (
|
||||||
|
<ALargeSmall
|
||||||
|
className="size-3.5"
|
||||||
|
style={{
|
||||||
|
color: "rgba(var(--color-text-100))",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
command: ({ editor, range }) => toggleTextColor(undefined, editor, range),
|
||||||
|
},
|
||||||
|
...COLORS_LIST.map(
|
||||||
|
(color) =>
|
||||||
|
({
|
||||||
|
commandKey: "text-color",
|
||||||
|
key: `text-color-${color.textColor}`,
|
||||||
|
title: color.label,
|
||||||
|
description: "Change text color",
|
||||||
|
searchTerms: ["color", "text", color.label],
|
||||||
|
icon: (
|
||||||
|
<ALargeSmall
|
||||||
|
className="size-3.5"
|
||||||
|
style={{
|
||||||
|
color: color.textColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
command: ({ editor, range }) => toggleTextColor(color.textColor, editor, range),
|
||||||
|
}) as ISlashCommandItem
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "background-color",
|
||||||
|
title: "Background colors",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
commandKey: "background-color",
|
||||||
|
key: "background-color-default",
|
||||||
|
title: "Default background",
|
||||||
|
description: "Change background color",
|
||||||
|
searchTerms: ["color", "bg", "background", "default"],
|
||||||
|
icon: <ALargeSmall className="size-3.5" />,
|
||||||
|
iconContainerStyle: {
|
||||||
|
borderRadius: "4px",
|
||||||
|
backgroundColor: "rgba(var(--color-background-100))",
|
||||||
|
border: "1px solid rgba(var(--color-border-300))",
|
||||||
|
},
|
||||||
|
command: ({ editor, range }) => toggleTextColor(undefined, editor, range),
|
||||||
|
},
|
||||||
|
...COLORS_LIST.map(
|
||||||
|
(color) =>
|
||||||
|
({
|
||||||
|
commandKey: "background-color",
|
||||||
|
key: `background-color-${color.backgroundColor}`,
|
||||||
|
title: `${color.label} background`,
|
||||||
|
description: "Change background color",
|
||||||
|
searchTerms: ["color", "bg", "background", color.label],
|
||||||
|
icon: <ALargeSmall className="size-3.5" />,
|
||||||
|
iconContainerStyle: {
|
||||||
|
borderRadius: "4px",
|
||||||
|
backgroundColor: color.backgroundColor,
|
||||||
|
},
|
||||||
|
command: ({ editor, range }) => toggleBackgroundColor(color.backgroundColor, editor, range),
|
||||||
|
}) as ISlashCommandItem
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const getSlashCommandFilteredSections =
|
||||||
|
(additionalOptions?: ISlashCommandItem[]) =>
|
||||||
|
({ query }: { query: string }): TSlashCommandSection[] => {
|
||||||
|
if (additionalOptions) {
|
||||||
|
additionalOptions.map((item) => SLASH_COMMAND_SECTIONS?.[0]?.items.push(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredSlashSections = SLASH_COMMAND_SECTIONS.map((section) => ({
|
||||||
|
...section,
|
||||||
|
items: section.items.filter((item) => {
|
||||||
|
if (typeof query !== "string") return;
|
||||||
|
|
||||||
|
const lowercaseQuery = query.toLowerCase();
|
||||||
|
return (
|
||||||
|
item.title.toLowerCase().includes(lowercaseQuery) ||
|
||||||
|
item.description.toLowerCase().includes(lowercaseQuery) ||
|
||||||
|
item.searchTerms.some((t) => t.includes(lowercaseQuery))
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return filteredSlashSections.filter((s) => s.items.length !== 0);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
// helpers
|
||||||
|
import { cn } from "@/helpers/common";
|
||||||
|
// types
|
||||||
|
import { ISlashCommandItem } from "@/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isSelected: boolean;
|
||||||
|
item: ISlashCommandItem;
|
||||||
|
itemIndex: number;
|
||||||
|
onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||||
|
onMouseEnter: () => void;
|
||||||
|
sectionIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CommandMenuItem: React.FC<Props> = (props) => {
|
||||||
|
const { isSelected, item, itemIndex, onClick, onMouseEnter, sectionIndex } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id={`item-${sectionIndex}-${itemIndex}`}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 w-full rounded px-1 py-1.5 text-sm text-left truncate text-custom-text-200",
|
||||||
|
{
|
||||||
|
"bg-custom-background-80": isSelected,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
>
|
||||||
|
<span className="size-5 grid place-items-center flex-shrink-0" style={item.iconContainerStyle}>
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
<p className="flex-grow truncate">{item.title}</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
// components
|
||||||
|
import { TSlashCommandSection } from "./command-items-list";
|
||||||
|
import { CommandMenuItem } from "./command-menu-item";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
items: TSlashCommandSection[];
|
||||||
|
command: any;
|
||||||
|
editor: any;
|
||||||
|
range: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SlashCommandsMenu = (props: Props) => {
|
||||||
|
const { items: sections, command } = props;
|
||||||
|
// states
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState({
|
||||||
|
section: 0,
|
||||||
|
item: 0,
|
||||||
|
});
|
||||||
|
// refs
|
||||||
|
const commandListContainer = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const selectItem = useCallback(
|
||||||
|
(sectionIndex: number, itemIndex: number) => {
|
||||||
|
const item = sections[sectionIndex].items[itemIndex];
|
||||||
|
if (item) command(item);
|
||||||
|
},
|
||||||
|
[command, sections]
|
||||||
|
);
|
||||||
|
// handle arrow key navigation
|
||||||
|
useEffect(() => {
|
||||||
|
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (navigationKeys.includes(e.key)) {
|
||||||
|
e.preventDefault();
|
||||||
|
const currentSection = selectedIndex.section;
|
||||||
|
const currentItem = selectedIndex.item;
|
||||||
|
let nextSection = currentSection;
|
||||||
|
let nextItem = currentItem;
|
||||||
|
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
nextItem = currentItem - 1;
|
||||||
|
if (nextItem < 0) {
|
||||||
|
nextSection = currentSection - 1;
|
||||||
|
if (nextSection < 0) nextSection = sections.length - 1;
|
||||||
|
nextItem = sections[nextSection].items.length - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
nextItem = currentItem + 1;
|
||||||
|
if (nextItem >= sections[currentSection].items.length) {
|
||||||
|
nextSection = currentSection + 1;
|
||||||
|
if (nextSection >= sections.length) nextSection = 0;
|
||||||
|
nextItem = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
selectItem(currentSection, currentItem);
|
||||||
|
}
|
||||||
|
setSelectedIndex({
|
||||||
|
section: nextSection,
|
||||||
|
item: nextItem,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
};
|
||||||
|
}, [sections, selectedIndex, setSelectedIndex, selectItem]);
|
||||||
|
// initialize the select index to 0 by default
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIndex({
|
||||||
|
section: 0,
|
||||||
|
item: 0,
|
||||||
|
});
|
||||||
|
}, [sections]);
|
||||||
|
// scroll to the dropdown item when navigating via keyboard
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const container = commandListContainer?.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const item = container.querySelector(`#item-${selectedIndex.section}-${selectedIndex.item}`) as HTMLElement;
|
||||||
|
|
||||||
|
// use scroll into view to bring the item in view if it is not in view
|
||||||
|
item?.scrollIntoView({ block: "nearest" });
|
||||||
|
}, [sections, selectedIndex]);
|
||||||
|
|
||||||
|
const areSearchResultsEmpty = sections.map((s) => s.items.length).reduce((acc, curr) => acc + curr, 0) === 0;
|
||||||
|
|
||||||
|
if (areSearchResultsEmpty) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id="slash-command"
|
||||||
|
ref={commandListContainer}
|
||||||
|
className="z-10 max-h-80 min-w-[12rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg space-y-2"
|
||||||
|
>
|
||||||
|
{sections.map((section, sectionIndex) => (
|
||||||
|
<div key={section.key} className="space-y-2">
|
||||||
|
{section.title && <h6 className="text-xs font-semibold text-custom-text-300">{section.title}</h6>}
|
||||||
|
<div>
|
||||||
|
{section.items.map((item, itemIndex) => (
|
||||||
|
<CommandMenuItem
|
||||||
|
key={item.key}
|
||||||
|
isSelected={sectionIndex === selectedIndex.section && itemIndex === selectedIndex.item}
|
||||||
|
item={item}
|
||||||
|
itemIndex={itemIndex}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
selectItem(sectionIndex, itemIndex);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() =>
|
||||||
|
setSelectedIndex({
|
||||||
|
section: sectionIndex,
|
||||||
|
item: itemIndex,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sectionIndex={sectionIndex}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./root";
|
||||||
113
packages/editor/src/core/extensions/slash-commands/root.tsx
Normal file
113
packages/editor/src/core/extensions/slash-commands/root.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { Editor, Range, Extension } from "@tiptap/core";
|
||||||
|
import { ReactRenderer } from "@tiptap/react";
|
||||||
|
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
|
||||||
|
import tippy from "tippy.js";
|
||||||
|
// types
|
||||||
|
import { ISlashCommandItem } from "@/types";
|
||||||
|
// components
|
||||||
|
import { getSlashCommandFilteredSections } from "./command-items-list";
|
||||||
|
import { SlashCommandsMenu } from "./command-menu";
|
||||||
|
|
||||||
|
export type SlashCommandOptions = {
|
||||||
|
suggestion: Omit<SuggestionOptions, "editor">;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Command = Extension.create<SlashCommandOptions>({
|
||||||
|
name: "slash-command",
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
suggestion: {
|
||||||
|
char: "/",
|
||||||
|
command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
|
||||||
|
props.command({ editor, range });
|
||||||
|
},
|
||||||
|
allow({ editor }: { editor: Editor }) {
|
||||||
|
const { selection } = editor.state;
|
||||||
|
|
||||||
|
const parentNode = selection.$from.node(selection.$from.depth);
|
||||||
|
const blockType = parentNode.type.name;
|
||||||
|
|
||||||
|
if (blockType === "codeBlock") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editor.isActive("table")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
Suggestion({
|
||||||
|
editor: this.editor,
|
||||||
|
...this.options.suggestion,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface CommandListInstance {
|
||||||
|
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderItems = () => {
|
||||||
|
let component: ReactRenderer<CommandListInstance, typeof SlashCommandsMenu> | null = null;
|
||||||
|
let popup: any | null = null;
|
||||||
|
return {
|
||||||
|
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
|
||||||
|
component = new ReactRenderer(SlashCommandsMenu, {
|
||||||
|
props,
|
||||||
|
editor: props.editor,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tippyContainer =
|
||||||
|
document.querySelector(".active-editor") ?? document.querySelector('[id^="editor-container"]');
|
||||||
|
|
||||||
|
// @ts-expect-error Tippy overloads are messed up
|
||||||
|
popup = tippy("body", {
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
appendTo: tippyContainer,
|
||||||
|
content: component.element,
|
||||||
|
showOnCreate: true,
|
||||||
|
interactive: true,
|
||||||
|
trigger: "manual",
|
||||||
|
placement: "bottom-start",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
|
||||||
|
component?.updateProps(props);
|
||||||
|
|
||||||
|
popup?.[0]?.setProps({
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||||
|
if (props.event.key === "Escape") {
|
||||||
|
popup?.[0].hide();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (component?.ref?.onKeyDown(props)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
onExit: () => {
|
||||||
|
popup?.[0].destroy();
|
||||||
|
component?.destroy();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SlashCommands = (additionalOptions?: ISlashCommandItem[]) =>
|
||||||
|
Command.configure({
|
||||||
|
suggestion: {
|
||||||
|
items: getSlashCommandFilteredSections(additionalOptions),
|
||||||
|
render: renderItems,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -154,3 +154,42 @@ export const unsetLinkEditor = (editor: Editor) => {
|
||||||
export const setLinkEditor = (editor: Editor, url: string) => {
|
export const setLinkEditor = (editor: Editor, url: string) => {
|
||||||
editor.chain().focus().setLink({ href: url }).run();
|
editor.chain().focus().setLink({ href: url }).run();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const toggleTextColor = (color: string | undefined, editor: Editor, range?: Range) => {
|
||||||
|
if (color) {
|
||||||
|
if (range) editor.chain().focus().deleteRange(range).setColor(color).run();
|
||||||
|
else editor.chain().focus().setColor(color).run();
|
||||||
|
} else {
|
||||||
|
if (range) editor.chain().focus().deleteRange(range).unsetColor().run();
|
||||||
|
else editor.chain().focus().unsetColor().run();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleBackgroundColor = (color: string | undefined, editor: Editor, range?: Range) => {
|
||||||
|
if (color) {
|
||||||
|
if (range) {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.setHighlight({
|
||||||
|
color,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.setHighlight({
|
||||||
|
color,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (range) {
|
||||||
|
editor.chain().focus().deleteRange(range).unsetHighlight().run();
|
||||||
|
} else {
|
||||||
|
editor.chain().focus().unsetHighlight().run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,8 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
insertContentAtSavedSelection(editorRef, content, savedSelection);
|
insertContentAtSavedSelection(editorRef, content, savedSelection);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
executeMenuItemCommand: (itemKey: TEditorCommands) => {
|
executeMenuItemCommand: (props) => {
|
||||||
|
const { itemKey } = props;
|
||||||
const editorItems = getEditorMenuItems(editorRef.current);
|
const editorItems = getEditorMenuItems(editorRef.current);
|
||||||
|
|
||||||
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
|
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
|
||||||
|
|
@ -145,6 +146,8 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
if (item) {
|
if (item) {
|
||||||
if (item.key === "image") {
|
if (item.key === "image") {
|
||||||
item.command(savedSelectionRef.current);
|
item.command(savedSelectionRef.current);
|
||||||
|
} else if (itemKey === "text-color" || itemKey === "background-color") {
|
||||||
|
item.command(props.color);
|
||||||
} else {
|
} else {
|
||||||
item.command();
|
item.command();
|
||||||
}
|
}
|
||||||
|
|
@ -152,12 +155,19 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
console.warn(`No command found for item: ${itemKey}`);
|
console.warn(`No command found for item: ${itemKey}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
isMenuItemActive: (itemName: TEditorCommands): boolean => {
|
isMenuItemActive: (props) => {
|
||||||
|
const { itemKey } = props;
|
||||||
const editorItems = getEditorMenuItems(editorRef.current);
|
const editorItems = getEditorMenuItems(editorRef.current);
|
||||||
|
|
||||||
const getEditorMenuItem = (itemName: TEditorCommands) => editorItems.find((item) => item.key === itemName);
|
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
|
||||||
const item = getEditorMenuItem(itemName);
|
const item = getEditorMenuItem(itemKey);
|
||||||
return item ? item.isActive() : false;
|
if (!item) return false;
|
||||||
|
|
||||||
|
if (itemKey === "text-color" || itemKey === "background-color") {
|
||||||
|
return item.isActive(props.color);
|
||||||
|
} else {
|
||||||
|
return item.isActive("");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onHeadingChange: (callback: (headings: IMarking[]) => void) => {
|
onHeadingChange: (callback: (headings: IMarking[]) => void) => {
|
||||||
// Subscribe to update event emitted from headers extension
|
// Subscribe to update event emitted from headers extension
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,15 @@ import {
|
||||||
IMentionHighlight,
|
IMentionHighlight,
|
||||||
IMentionSuggestion,
|
IMentionSuggestion,
|
||||||
TAIHandler,
|
TAIHandler,
|
||||||
|
TColorEditorCommands,
|
||||||
TDisplayConfig,
|
TDisplayConfig,
|
||||||
TEditorCommands,
|
TEditorCommands,
|
||||||
TEmbedConfig,
|
TEmbedConfig,
|
||||||
TExtensions,
|
TExtensions,
|
||||||
TFileHandler,
|
TFileHandler,
|
||||||
|
TNonColorEditorCommands,
|
||||||
TServerHandler,
|
TServerHandler,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
|
|
||||||
// editor refs
|
// editor refs
|
||||||
export type EditorReadOnlyRefApi = {
|
export type EditorReadOnlyRefApi = {
|
||||||
getMarkDown: () => string;
|
getMarkDown: () => string;
|
||||||
|
|
@ -36,8 +37,26 @@ export type EditorReadOnlyRefApi = {
|
||||||
|
|
||||||
export interface EditorRefApi extends EditorReadOnlyRefApi {
|
export interface EditorRefApi extends EditorReadOnlyRefApi {
|
||||||
setEditorValueAtCursorPosition: (content: string) => void;
|
setEditorValueAtCursorPosition: (content: string) => void;
|
||||||
executeMenuItemCommand: (itemKey: TEditorCommands) => void;
|
executeMenuItemCommand: (
|
||||||
isMenuItemActive: (itemKey: TEditorCommands) => boolean;
|
props:
|
||||||
|
| {
|
||||||
|
itemKey: TNonColorEditorCommands;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
itemKey: TColorEditorCommands;
|
||||||
|
color: string | undefined;
|
||||||
|
}
|
||||||
|
) => void;
|
||||||
|
isMenuItemActive: (
|
||||||
|
props:
|
||||||
|
| {
|
||||||
|
itemKey: TNonColorEditorCommands;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
itemKey: TColorEditorCommands;
|
||||||
|
color: string | undefined;
|
||||||
|
}
|
||||||
|
) => boolean;
|
||||||
onStateChange: (callback: () => void) => () => void;
|
onStateChange: (callback: () => void) => () => void;
|
||||||
setFocusAtPosition: (position: number) => void;
|
setFocusAtPosition: (position: number) => void;
|
||||||
isEditorReadyToDiscard: () => boolean;
|
isEditorReadyToDiscard: () => boolean;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ReactNode } from "react";
|
import { CSSProperties } from "react";
|
||||||
import { Editor, Range } from "@tiptap/core";
|
import { Editor, Range } from "@tiptap/core";
|
||||||
|
|
||||||
export type TEditorCommands =
|
export type TEditorCommands =
|
||||||
|
|
@ -21,7 +21,12 @@ export type TEditorCommands =
|
||||||
| "table"
|
| "table"
|
||||||
| "image"
|
| "image"
|
||||||
| "divider"
|
| "divider"
|
||||||
| "issue-embed";
|
| "issue-embed"
|
||||||
|
| "text-color"
|
||||||
|
| "background-color";
|
||||||
|
|
||||||
|
export type TColorEditorCommands = Extract<TEditorCommands, "text-color" | "background-color">;
|
||||||
|
export type TNonColorEditorCommands = Exclude<TEditorCommands, "text-color" | "background-color">;
|
||||||
|
|
||||||
export type CommandProps = {
|
export type CommandProps = {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
|
|
@ -29,10 +34,12 @@ export type CommandProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ISlashCommandItem = {
|
export type ISlashCommandItem = {
|
||||||
key: TEditorCommands;
|
commandKey: TEditorCommands;
|
||||||
|
key: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
searchTerms: string[];
|
searchTerms: string[];
|
||||||
icon: ReactNode;
|
icon: React.ReactNode;
|
||||||
|
iconContainerStyle?: CSSProperties;
|
||||||
command: ({ editor, range }: CommandProps) => void;
|
command: ({ editor, range }: CommandProps) => void;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ export {
|
||||||
|
|
||||||
export { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection";
|
export { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection";
|
||||||
|
|
||||||
|
// constants
|
||||||
|
export * from "@/constants/common";
|
||||||
|
|
||||||
// helpers
|
// helpers
|
||||||
export * from "@/helpers/common";
|
export * from "@/helpers/common";
|
||||||
export * from "@/helpers/editor-commands";
|
export * from "@/helpers/editor-commands";
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
// editor
|
// editor
|
||||||
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor";
|
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef, TNonColorEditorCommands } from "@plane/editor";
|
||||||
// components
|
// components
|
||||||
import { IssueCommentToolbar } from "@/components/editor";
|
import { IssueCommentToolbar } from "@/components/editor";
|
||||||
// helpers
|
// helpers
|
||||||
|
|
@ -56,7 +56,9 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
|
||||||
<IssueCommentToolbar
|
<IssueCommentToolbar
|
||||||
executeCommand={(key) => {
|
executeCommand={(key) => {
|
||||||
if (isMutableRefObject<EditorRefApi>(ref)) {
|
if (isMutableRefObject<EditorRefApi>(ref)) {
|
||||||
ref.current?.executeMenuItemCommand(key);
|
ref.current?.executeMenuItemCommand({
|
||||||
|
itemKey: key as TNonColorEditorCommands,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import React, { useEffect, useState, useCallback } from "react";
|
import React, { useEffect, useState, useCallback } from "react";
|
||||||
// editor
|
// editor
|
||||||
import { EditorRefApi, TEditorCommands } from "@plane/editor";
|
import { EditorRefApi, TEditorCommands, TNonColorEditorCommands } from "@plane/editor";
|
||||||
// ui
|
// ui
|
||||||
import { Button, Tooltip } from "@plane/ui";
|
import { Button, Tooltip } from "@plane/ui";
|
||||||
// constants
|
// constants
|
||||||
|
|
@ -34,7 +34,9 @@ export const IssueCommentToolbar: React.FC<Props> = (props) => {
|
||||||
.flat()
|
.flat()
|
||||||
.forEach((item) => {
|
.forEach((item) => {
|
||||||
// Assert that editorRef.current is not null
|
// Assert that editorRef.current is not null
|
||||||
newActiveStates[item.key] = (editorRef.current as EditorRefApi).isMenuItemActive(item.key);
|
newActiveStates[item.key] = (editorRef.current as EditorRefApi).isMenuItemActive({
|
||||||
|
itemKey: item.key as TNonColorEditorCommands,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
setActiveStates(newActiveStates);
|
setActiveStates(newActiveStates);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
// editor
|
// editor
|
||||||
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor";
|
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef, TNonColorEditorCommands } from "@plane/editor";
|
||||||
// types
|
// types
|
||||||
import { IUserLite } from "@plane/types";
|
import { IUserLite } from "@plane/types";
|
||||||
// components
|
// components
|
||||||
|
|
@ -87,7 +87,9 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
|
||||||
accessSpecifier={accessSpecifier}
|
accessSpecifier={accessSpecifier}
|
||||||
executeCommand={(key) => {
|
executeCommand={(key) => {
|
||||||
if (isMutableRefObject<EditorRefApi>(ref)) {
|
if (isMutableRefObject<EditorRefApi>(ref)) {
|
||||||
ref.current?.executeMenuItemCommand(key);
|
ref.current?.executeMenuItemCommand({
|
||||||
|
itemKey: key as TNonColorEditorCommands,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
handleAccessChange={handleAccessChange}
|
handleAccessChange={handleAccessChange}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import React, { useEffect, useState, useCallback } from "react";
|
import React, { useEffect, useState, useCallback } from "react";
|
||||||
import { Globe2, Lock, LucideIcon } from "lucide-react";
|
import { Globe2, Lock, LucideIcon } from "lucide-react";
|
||||||
// editor
|
// editor
|
||||||
import { EditorRefApi, TEditorCommands } from "@plane/editor";
|
import { EditorRefApi, TEditorCommands, TNonColorEditorCommands } from "@plane/editor";
|
||||||
// ui
|
// ui
|
||||||
import { Button, Tooltip } from "@plane/ui";
|
import { Button, Tooltip } from "@plane/ui";
|
||||||
// constants
|
// constants
|
||||||
|
|
@ -69,7 +69,9 @@ export const IssueCommentToolbar: React.FC<Props> = (props) => {
|
||||||
.flat()
|
.flat()
|
||||||
.forEach((item) => {
|
.forEach((item) => {
|
||||||
// Assert that editorRef.current is not null
|
// Assert that editorRef.current is not null
|
||||||
newActiveStates[item.key] = (editorRef.current as EditorRefApi).isMenuItemActive(item.key);
|
newActiveStates[item.key] = (editorRef.current as EditorRefApi).isMenuItemActive({
|
||||||
|
itemKey: item.key as TNonColorEditorCommands,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
setActiveStates(newActiveStates);
|
setActiveStates(newActiveStates);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
127
web/core/components/pages/editor/header/color-dropdown.tsx
Normal file
127
web/core/components/pages/editor/header/color-dropdown.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
import { Popover } from "@headlessui/react";
|
||||||
|
import { ALargeSmall, Ban } from "lucide-react";
|
||||||
|
// plane editor
|
||||||
|
import { COLORS_LIST, TColorEditorCommands } from "@plane/editor";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "@/helpers/common.helper";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
handleColorSelect: (key: TColorEditorCommands, color: string | undefined) => void;
|
||||||
|
isColorActive: (key: TColorEditorCommands, color: string | undefined) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ColorDropdown: React.FC<Props> = memo((props) => {
|
||||||
|
const { handleColorSelect, isColorActive } = props;
|
||||||
|
|
||||||
|
const activeTextColor = COLORS_LIST.find((c) => isColorActive("text-color", c.textColor));
|
||||||
|
const activeBackgroundColor = COLORS_LIST.find((c) => isColorActive("background-color", c.backgroundColor));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover as="div" className="h-7 px-2">
|
||||||
|
<Popover.Button
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
className={({ open }) =>
|
||||||
|
cn("h-full", {
|
||||||
|
"outline-none": open,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ open }) => (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"h-full px-2 text-custom-text-300 text-sm flex items-center gap-1.5 rounded hover:bg-custom-background-80",
|
||||||
|
{
|
||||||
|
"text-custom-text-100 bg-custom-background-80": open,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Color
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex-shrink-0 size-6 grid place-items-center rounded border-[0.5px] border-custom-border-300",
|
||||||
|
{
|
||||||
|
"bg-custom-background-100": !activeBackgroundColor,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
activeBackgroundColor
|
||||||
|
? {
|
||||||
|
backgroundColor: activeBackgroundColor.backgroundColor,
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ALargeSmall
|
||||||
|
className={cn("size-3.5", {
|
||||||
|
"text-custom-text-100": !activeTextColor,
|
||||||
|
})}
|
||||||
|
style={
|
||||||
|
activeTextColor
|
||||||
|
? {
|
||||||
|
color: activeTextColor.textColor,
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Popover.Button>
|
||||||
|
<Popover.Panel
|
||||||
|
as="div"
|
||||||
|
className="fixed z-20 mt-1 rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 shadow-custom-shadow-rg p-2 space-y-2"
|
||||||
|
>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs text-custom-text-300 font-semibold">Text colors</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{COLORS_LIST.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color.textColor}
|
||||||
|
type="button"
|
||||||
|
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
|
||||||
|
style={{
|
||||||
|
backgroundColor: color.textColor,
|
||||||
|
}}
|
||||||
|
onClick={() => handleColorSelect("text-color", color.textColor)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
|
||||||
|
onClick={() => handleColorSelect("text-color", undefined)}
|
||||||
|
>
|
||||||
|
<Ban className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs text-custom-text-300 font-semibold">Background colors</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{COLORS_LIST.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color.backgroundColor}
|
||||||
|
type="button"
|
||||||
|
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
|
||||||
|
style={{
|
||||||
|
backgroundColor: color.backgroundColor,
|
||||||
|
}}
|
||||||
|
onClick={() => handleColorSelect("background-color", color.backgroundColor)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
|
||||||
|
onClick={() => handleColorSelect("background-color", undefined)}
|
||||||
|
>
|
||||||
|
<Ban className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popover.Panel>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
export * from "./color-dropdown";
|
||||||
export * from "./extra-options";
|
export * from "./extra-options";
|
||||||
export * from "./info-popover";
|
export * from "./info-popover";
|
||||||
export * from "./options-dropdown";
|
export * from "./options-dropdown";
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@
|
||||||
import React, { useEffect, useState, useCallback } from "react";
|
import React, { useEffect, useState, useCallback } from "react";
|
||||||
import { Check, ChevronDown } from "lucide-react";
|
import { Check, ChevronDown } from "lucide-react";
|
||||||
// editor
|
// editor
|
||||||
import { EditorRefApi, TEditorCommands } from "@plane/editor";
|
import { EditorRefApi, TNonColorEditorCommands } from "@plane/editor";
|
||||||
// ui
|
// ui
|
||||||
import { CustomMenu, Tooltip } from "@plane/ui";
|
import { CustomMenu, Tooltip } from "@plane/ui";
|
||||||
|
// components
|
||||||
|
import { ColorDropdown } from "@/components/pages";
|
||||||
// constants
|
// constants
|
||||||
import { TOOLBAR_ITEMS, TYPOGRAPHY_ITEMS, ToolbarMenuItem } from "@/constants/editor";
|
import { TOOLBAR_ITEMS, TYPOGRAPHY_ITEMS, ToolbarMenuItem } from "@/constants/editor";
|
||||||
// helpers
|
// helpers
|
||||||
|
|
@ -18,7 +20,7 @@ type Props = {
|
||||||
type ToolbarButtonProps = {
|
type ToolbarButtonProps = {
|
||||||
item: ToolbarMenuItem;
|
item: ToolbarMenuItem;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
executeCommand: (commandKey: TEditorCommands) => void;
|
executeCommand: EditorRefApi["executeMenuItemCommand"];
|
||||||
};
|
};
|
||||||
|
|
||||||
const ToolbarButton: React.FC<ToolbarButtonProps> = React.memo((props) => {
|
const ToolbarButton: React.FC<ToolbarButtonProps> = React.memo((props) => {
|
||||||
|
|
@ -36,7 +38,11 @@ const ToolbarButton: React.FC<ToolbarButtonProps> = React.memo((props) => {
|
||||||
<button
|
<button
|
||||||
key={item.key}
|
key={item.key}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => executeCommand(item.key)}
|
onClick={() =>
|
||||||
|
executeCommand({
|
||||||
|
itemKey: item.key as TNonColorEditorCommands,
|
||||||
|
})
|
||||||
|
}
|
||||||
className={cn("grid size-7 place-items-center rounded text-custom-text-300 hover:bg-custom-background-80", {
|
className={cn("grid size-7 place-items-center rounded text-custom-text-300 hover:bg-custom-background-80", {
|
||||||
"bg-custom-background-80 text-custom-text-100": isActive,
|
"bg-custom-background-80 text-custom-text-100": isActive,
|
||||||
})}
|
})}
|
||||||
|
|
@ -56,6 +62,7 @@ ToolbarButton.displayName = "ToolbarButton";
|
||||||
const toolbarItems = TOOLBAR_ITEMS.document;
|
const toolbarItems = TOOLBAR_ITEMS.document;
|
||||||
|
|
||||||
export const PageToolbar: React.FC<Props> = ({ editorRef }) => {
|
export const PageToolbar: React.FC<Props> = ({ editorRef }) => {
|
||||||
|
// states
|
||||||
const [activeStates, setActiveStates] = useState<Record<string, boolean>>({});
|
const [activeStates, setActiveStates] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const updateActiveStates = useCallback(() => {
|
const updateActiveStates = useCallback(() => {
|
||||||
|
|
@ -63,7 +70,9 @@ export const PageToolbar: React.FC<Props> = ({ editorRef }) => {
|
||||||
Object.values(toolbarItems)
|
Object.values(toolbarItems)
|
||||||
.flat()
|
.flat()
|
||||||
.forEach((item) => {
|
.forEach((item) => {
|
||||||
newActiveStates[item.key] = editorRef.isMenuItemActive(item.key);
|
newActiveStates[item.key] = editorRef.isMenuItemActive({
|
||||||
|
itemKey: item.key as TNonColorEditorCommands,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
setActiveStates(newActiveStates);
|
setActiveStates(newActiveStates);
|
||||||
}, [editorRef]);
|
}, [editorRef]);
|
||||||
|
|
@ -74,7 +83,11 @@ export const PageToolbar: React.FC<Props> = ({ editorRef }) => {
|
||||||
return () => unsubscribe();
|
return () => unsubscribe();
|
||||||
}, [editorRef, updateActiveStates]);
|
}, [editorRef, updateActiveStates]);
|
||||||
|
|
||||||
const activeTypography = TYPOGRAPHY_ITEMS.find((item) => editorRef.isMenuItemActive(item.key));
|
const activeTypography = TYPOGRAPHY_ITEMS.find((item) =>
|
||||||
|
editorRef.isMenuItemActive({
|
||||||
|
itemKey: item.key as TNonColorEditorCommands,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center divide-x divide-custom-border-200">
|
<div className="flex flex-wrap items-center divide-x divide-custom-border-200">
|
||||||
|
|
@ -94,7 +107,11 @@ export const PageToolbar: React.FC<Props> = ({ editorRef }) => {
|
||||||
<CustomMenu.MenuItem
|
<CustomMenu.MenuItem
|
||||||
key={item.key}
|
key={item.key}
|
||||||
className="flex items-center justify-between gap-2"
|
className="flex items-center justify-between gap-2"
|
||||||
onClick={() => editorRef.executeMenuItemCommand(item.key)}
|
onClick={() =>
|
||||||
|
editorRef.executeMenuItemCommand({
|
||||||
|
itemKey: item.key as TNonColorEditorCommands,
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<item.icon className="size-3" />
|
<item.icon className="size-3" />
|
||||||
|
|
@ -104,6 +121,20 @@ export const PageToolbar: React.FC<Props> = ({ editorRef }) => {
|
||||||
</CustomMenu.MenuItem>
|
</CustomMenu.MenuItem>
|
||||||
))}
|
))}
|
||||||
</CustomMenu>
|
</CustomMenu>
|
||||||
|
<ColorDropdown
|
||||||
|
handleColorSelect={(key, color) =>
|
||||||
|
editorRef.executeMenuItemCommand({
|
||||||
|
itemKey: key,
|
||||||
|
color,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
isColorActive={(key, color) =>
|
||||||
|
editorRef.isMenuItemActive({
|
||||||
|
itemKey: key,
|
||||||
|
color,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
{Object.keys(toolbarItems).map((key) => (
|
{Object.keys(toolbarItems).map((key) => (
|
||||||
<div key={key} className="flex items-center gap-0.5 px-2 first:pl-0 last:pr-0">
|
<div key={key} className="flex items-center gap-0.5 px-2 first:pl-0 last:pr-0">
|
||||||
{toolbarItems[key].map((item) => (
|
{toolbarItems[key].map((item) => (
|
||||||
|
|
|
||||||
12
yarn.lock
12
yarn.lock
|
|
@ -3636,6 +3636,11 @@
|
||||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-collaboration/-/extension-collaboration-2.8.0.tgz#db7a1e600c80229ed24a9d004f290d9e8bd4d0f6"
|
resolved "https://registry.yarnpkg.com/@tiptap/extension-collaboration/-/extension-collaboration-2.8.0.tgz#db7a1e600c80229ed24a9d004f290d9e8bd4d0f6"
|
||||||
integrity sha512-Ae5NZWj2aq8ZElsGxQiq3cAxxbb0cR7VHvmIG1mPA6USvrQL6/xtBVutersBqINFELmIuxh/jm8qVffBm2qXyg==
|
integrity sha512-Ae5NZWj2aq8ZElsGxQiq3cAxxbb0cR7VHvmIG1mPA6USvrQL6/xtBVutersBqINFELmIuxh/jm8qVffBm2qXyg==
|
||||||
|
|
||||||
|
"@tiptap/extension-color@^2.7.1":
|
||||||
|
version "2.8.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@tiptap/extension-color/-/extension-color-2.8.0.tgz#597e1ea2e675e3c01ba64933008eacd296913abd"
|
||||||
|
integrity sha512-b0ZIDaZKTDVdTb0PMgtOiPzgCkYhvDldjzdWyPLsjWup5x9/zPasH5X/2SfMuwtjt+cKj6YBPveJjF7w5ApK7w==
|
||||||
|
|
||||||
"@tiptap/extension-document@^2.8.0":
|
"@tiptap/extension-document@^2.8.0":
|
||||||
version "2.8.0"
|
version "2.8.0"
|
||||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-2.8.0.tgz#7dc5d2622168ad5b81134a92fccf49d7be53f141"
|
resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-2.8.0.tgz#7dc5d2622168ad5b81134a92fccf49d7be53f141"
|
||||||
|
|
@ -3668,6 +3673,11 @@
|
||||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-heading/-/extension-heading-2.8.0.tgz#1b7711860fe9f4336fb8933110a129150faa4e39"
|
resolved "https://registry.yarnpkg.com/@tiptap/extension-heading/-/extension-heading-2.8.0.tgz#1b7711860fe9f4336fb8933110a129150faa4e39"
|
||||||
integrity sha512-4inWgrTPiqlivPmEHFOM5ck2UsmOsbKKPtqga6bALvWPmCv24S6/EBwFp8Jz4YABabXDnkviihmGu0LpP9D69w==
|
integrity sha512-4inWgrTPiqlivPmEHFOM5ck2UsmOsbKKPtqga6bALvWPmCv24S6/EBwFp8Jz4YABabXDnkviihmGu0LpP9D69w==
|
||||||
|
|
||||||
|
"@tiptap/extension-highlight@^2.7.1":
|
||||||
|
version "2.8.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@tiptap/extension-highlight/-/extension-highlight-2.8.0.tgz#3970f42a5a116745fbb2b82cfc5055adb04158e9"
|
||||||
|
integrity sha512-vyqX7D449nuARhI0AyRqtIZReFg3sfc/U/q1p3JOjtUoW6z2jmDTzshiKRrSg+Jf7Hhzj1pqwU+6+CpelPPDpA==
|
||||||
|
|
||||||
"@tiptap/extension-history@^2.8.0":
|
"@tiptap/extension-history@^2.8.0":
|
||||||
version "2.8.0"
|
version "2.8.0"
|
||||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-history/-/extension-history-2.8.0.tgz#06505cbdaa29a9791911eddbee54304ee32b1d5c"
|
resolved "https://registry.yarnpkg.com/@tiptap/extension-history/-/extension-history-2.8.0.tgz#06505cbdaa29a9791911eddbee54304ee32b1d5c"
|
||||||
|
|
@ -3728,7 +3738,7 @@
|
||||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-task-list/-/extension-task-list-2.8.0.tgz#b4124b638ee50251cae0bad9280a07350dc39e76"
|
resolved "https://registry.yarnpkg.com/@tiptap/extension-task-list/-/extension-task-list-2.8.0.tgz#b4124b638ee50251cae0bad9280a07350dc39e76"
|
||||||
integrity sha512-2Zkq0UojPh+Y4trJcNSsjkdHsYczxFReUqhzl5T0/OuPzIcDYL2OicUrsp36y16KKnH+WSOUOR8twDfR9LHygg==
|
integrity sha512-2Zkq0UojPh+Y4trJcNSsjkdHsYczxFReUqhzl5T0/OuPzIcDYL2OicUrsp36y16KKnH+WSOUOR8twDfR9LHygg==
|
||||||
|
|
||||||
"@tiptap/extension-text-style@^2.1.13":
|
"@tiptap/extension-text-style@^2.7.1":
|
||||||
version "2.8.0"
|
version "2.8.0"
|
||||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-text-style/-/extension-text-style-2.8.0.tgz#32e30ccf3853202eba2169ba5db30b9470df9644"
|
resolved "https://registry.yarnpkg.com/@tiptap/extension-text-style/-/extension-text-style-2.8.0.tgz#32e30ccf3853202eba2169ba5db30b9470df9644"
|
||||||
integrity sha512-jJp0vcZ2Ty7RvIL0VU6dm1y+fTfXq1lN2GwtYzYM0ueFuESa+Qo8ticYOImyWZ3wGJGVrjn7OV9r0ReW0/NYkQ==
|
integrity sha512-jJp0vcZ2Ty7RvIL0VU6dm1y+fTfXq1lN2GwtYzYM0ueFuESa+Qo8ticYOImyWZ3wGJGVrjn7OV9r0ReW0/NYkQ==
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue