diff --git a/packages/editor/package.json b/packages/editor/package.json index bfb379f8b60..01d03db1fd4 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -42,13 +42,15 @@ "@tiptap/extension-blockquote": "^2.1.13", "@tiptap/extension-character-count": "^2.6.5", "@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-list-item": "^2.1.13", "@tiptap/extension-mention": "^2.1.13", "@tiptap/extension-placeholder": "^2.3.0", "@tiptap/extension-task-item": "^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/pm": "^2.1.13", "@tiptap/react": "^2.1.13", diff --git a/packages/editor/src/ce/extensions/document-extensions.tsx b/packages/editor/src/ce/extensions/document-extensions.tsx index 93900700b27..2809fcee4ef 100644 --- a/packages/editor/src/ce/extensions/document-extensions.tsx +++ b/packages/editor/src/ce/extensions/document-extensions.tsx @@ -1,6 +1,6 @@ import { HocuspocusProvider } from "@hocuspocus/provider"; import { Extensions } from "@tiptap/core"; -import { SlashCommand } from "@/extensions"; +import { SlashCommands } from "@/extensions"; // plane editor types import { TIssueEmbedConfig } from "@/plane-editor/types"; // types @@ -14,7 +14,7 @@ type Props = { }; export const DocumentEditorAdditionalExtensions = (_props: Props) => { - const extensions: Extensions = [SlashCommand()]; + const extensions: Extensions = [SlashCommands()]; return extensions; }; diff --git a/packages/editor/src/core/components/editors/rich-text/editor.tsx b/packages/editor/src/core/components/editors/rich-text/editor.tsx index fe4d2d51373..53f766ee21a 100644 --- a/packages/editor/src/core/components/editors/rich-text/editor.tsx +++ b/packages/editor/src/core/components/editors/rich-text/editor.tsx @@ -3,7 +3,7 @@ import { forwardRef, useCallback } from "react"; import { EditorWrapper } from "@/components/editors"; import { EditorBubbleMenu } from "@/components/menus"; // extensions -import { SideMenuExtension, SlashCommand } from "@/extensions"; +import { SideMenuExtension, SlashCommands } from "@/extensions"; // types import { EditorRefApi, IRichTextEditor } from "@/types"; @@ -11,7 +11,7 @@ const RichTextEditor = (props: IRichTextEditor) => { const { dragDropEnabled } = props; const getExtensions = useCallback(() => { - const extensions = [SlashCommand()]; + const extensions = [SlashCommands()]; extensions.push( SideMenuExtension({ diff --git a/packages/editor/src/core/components/menus/bubble-menu/color-selector.tsx b/packages/editor/src/core/components/menus/bubble-menu/color-selector.tsx new file mode 100644 index 00000000000..cc3eb5412e2 --- /dev/null +++ b/packages/editor/src/core/components/menus/bubble-menu/color-selector.tsx @@ -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>; +}; + +export const BubbleMenuColorSelector: FC = (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 ( +
+ + {isOpen && ( +
+
+

Text colors

+
+ {COLORS_LIST.map((color) => ( + +
+
+
+

Background colors

+
+ {COLORS_LIST.map((color) => ( + +
+
+
+ )} +
+ ); +}; diff --git a/packages/editor/src/core/components/menus/bubble-menu/index.ts b/packages/editor/src/core/components/menus/bubble-menu/index.ts index 71a98bada08..526feed3d1e 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/index.ts +++ b/packages/editor/src/core/components/menus/bubble-menu/index.ts @@ -1,3 +1,4 @@ +export * from "./color-selector"; export * from "./link-selector"; export * from "./node-selector"; export * from "./root"; diff --git a/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx b/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx index 20335e8abb6..eaa20ed26bb 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx @@ -1,6 +1,6 @@ import { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react"; import { Editor } from "@tiptap/core"; -import { Check, Trash } from "lucide-react"; +import { Check, Link, Trash } from "lucide-react"; // helpers import { cn, isValidHttpUrl } from "@/helpers/common"; import { setLinkEditor, unsetLinkEditor } from "@/helpers/editor-commands"; @@ -11,7 +11,9 @@ type Props = { setIsOpen: Dispatch>; }; -export const BubbleMenuLinkSelector: FC = ({ editor, isOpen, setIsOpen }) => { +export const BubbleMenuLinkSelector: FC = (props) => { + const { editor, isOpen, setIsOpen } = props; + // refs const inputRef = useRef(null); const onLinkSubmit = useCallback(() => { @@ -28,26 +30,23 @@ export const BubbleMenuLinkSelector: FC = ({ editor, isOpen, setIsOpen }) }); return ( -
+
{isOpen && (
>; }; -export const BubbleMenuNodeSelector: FC = ({ editor, isOpen, setIsOpen }) => { - const items: BubbleMenuItem[] = [ +export const BubbleMenuNodeSelector: FC = (props) => { + const { editor, isOpen, setIsOpen } = props; + + const items: EditorMenuItem[] = [ TextItem(editor), HeadingOneItem(editor), HeadingTwoItem(editor), @@ -42,7 +44,7 @@ export const BubbleMenuNodeSelector: FC = ({ editor, isOpen, setIsOpen }) CodeItem(editor), ]; - const activeItem = items.filter((item) => item.isActive()).pop() ?? { + const activeItem = items.filter((item) => item.isActive("")).pop() ?? { name: "Multiple", }; @@ -54,12 +56,11 @@ export const BubbleMenuNodeSelector: FC = ({ editor, isOpen, setIsOpen }) setIsOpen(!isOpen); 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" > {activeItem?.name} - + - {isOpen && (
{items.map((item) => ( diff --git a/packages/editor/src/core/components/menus/bubble-menu/root.tsx b/packages/editor/src/core/components/menus/bubble-menu/root.tsx index ec72f154086..0f789dd8a1b 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/root.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/root.tsx @@ -1,12 +1,13 @@ import { FC, useEffect, useState } from "react"; import { BubbleMenu, BubbleMenuProps, isNodeSelection } from "@tiptap/react"; -import { LucideIcon } from "lucide-react"; // components import { BoldItem, + BubbleMenuColorSelector, BubbleMenuLinkSelector, BubbleMenuNodeSelector, CodeItem, + EditorMenuItem, ItalicItem, StrikeThroughItem, UnderLineItem, @@ -16,34 +17,23 @@ import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-sele // helpers import { cn } from "@/helpers/common"; -export interface BubbleMenuItem { - key: string; - name: string; - isActive: () => boolean; - command: () => void; - icon: LucideIcon; -} - type EditorBubbleMenuProps = Omit; export const EditorBubbleMenu: FC = (props: any) => { - const items: BubbleMenuItem[] = [ - ...(props.editor.isActive("code") - ? [] - : [ - BoldItem(props.editor), - ItalicItem(props.editor), - UnderLineItem(props.editor), - StrikeThroughItem(props.editor), - ]), - CodeItem(props.editor), - ]; + // states + const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false); + const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false); + const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false); + const [isSelecting, setIsSelecting] = useState(false); + + const items: EditorMenuItem[] = props.editor.isActive("code") + ? [CodeItem(props.editor)] + : [BoldItem(props.editor), ItalicItem(props.editor), UnderLineItem(props.editor), StrikeThroughItem(props.editor)]; const bubbleMenuProps: EditorBubbleMenuProps = { ...props, shouldShow: ({ state, editor }) => { const { selection } = state; - const { empty } = selection; if ( @@ -63,15 +53,11 @@ export const EditorBubbleMenu: FC = (props: any) => { onHidden: () => { setIsNodeSelectorOpen(false); setIsLinkSelectorOpen(false); + setIsColorSelectorOpen(false); }, }, }; - const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false); - const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false); - - const [isSelecting, setIsSelecting] = useState(false); - useEffect(() => { function handleMouseDown() { function handleMouseMove() { @@ -102,51 +88,66 @@ export const EditorBubbleMenu: FC = (props: any) => { return ( - {isSelecting ? null : ( + {!isSelecting && ( <> - {!props.editor.isActive("table") && ( - { - setIsNodeSelectorOpen(!isNodeSelectorOpen); - setIsLinkSelectorOpen(false); - }} - /> - )} - {!props.editor.isActive("code") && ( - { - setIsLinkSelectorOpen(!isLinkSelectorOpen); - setIsNodeSelectorOpen(false); - }} - /> - )} -
+
+ {!props.editor.isActive("table") && ( + { + setIsNodeSelectorOpen((prev) => !prev); + setIsLinkSelectorOpen(false); + setIsColorSelectorOpen(false); + }} + /> + )} +
+
+ {!props.editor.isActive("code") && ( + { + setIsLinkSelectorOpen((prev) => !prev); + setIsNodeSelectorOpen(false); + setIsColorSelectorOpen(false); + }} + /> + )} +
+
+ {!props.editor.isActive("code") && ( + { + setIsColorSelectorOpen((prev) => !prev); + setIsNodeSelectorOpen(false); + setIsLinkSelectorOpen(false); + }} + /> + )} +
+
{items.map((item) => ( ))}
diff --git a/packages/editor/src/core/components/menus/menu-items.ts b/packages/editor/src/core/components/menus/menu-items.ts index cf10081f1e5..f7082c12de0 100644 --- a/packages/editor/src/core/components/menus/menu-items.ts +++ b/packages/editor/src/core/components/menus/menu-items.ts @@ -20,12 +20,14 @@ import { Heading6, CaseSensitive, LucideIcon, + Palette, } from "lucide-react"; // helpers import { insertImage, insertTableCommand, setText, + toggleBackgroundColor, toggleBlockquote, toggleBold, toggleBulletList, @@ -40,18 +42,26 @@ import { toggleOrderedList, toggleStrike, toggleTaskList, + toggleTextColor, toggleUnderline, } from "@/helpers/editor-commands"; // types -import { TEditorCommands } from "@/types"; +import { TColorEditorCommands, TNonColorEditorCommands } from "@/types"; -export interface EditorMenuItem { - key: TEditorCommands; +export type EditorMenuItem = { name: string; - isActive: () => boolean; - command: () => void; + command: (...args: any) => void; icon: LucideIcon; -} +} & ( + | { + key: TNonColorEditorCommands; + isActive: () => boolean; + } + | { + key: TColorEditorCommands; + isActive: (color: string | undefined) => boolean; + } +); export const TextItem = (editor: Editor): EditorMenuItem => ({ key: "text", @@ -198,10 +208,25 @@ export const ImageItem = (editor: Editor) => icon: ImageIcon, }) as const; -export function getEditorMenuItems(editor: Editor | null) { - if (!editor) { - return []; - } +export const TextColorItem = (editor: Editor): EditorMenuItem => ({ + key: "text-color", + 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 [ TextItem(editor), HeadingOneItem(editor), @@ -221,5 +246,7 @@ export function getEditorMenuItems(editor: Editor | null) { QuoteItem(editor), TableItem(editor), ImageItem(editor), + TextColorItem(editor), + BackgroundColorItem(editor), ]; -} +}; diff --git a/packages/editor/src/core/constants/common.ts b/packages/editor/src/core/constants/common.ts new file mode 100644 index 00000000000..4e46fb83769 --- /dev/null +++ b/packages/editor/src/core/constants/common.ts @@ -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", + }, +]; diff --git a/packages/editor/src/core/extensions/core-without-props.ts b/packages/editor/src/core/extensions/core-without-props.ts index 1cedd513966..7fc0ae6d052 100644 --- a/packages/editor/src/core/extensions/core-without-props.ts +++ b/packages/editor/src/core/extensions/core-without-props.ts @@ -1,3 +1,5 @@ +import { Color } from "@tiptap/extension-color"; +import Highlight from "@tiptap/extension-highlight"; import TaskItem from "@tiptap/extension-task-item"; import TaskList from "@tiptap/extension-task-list"; import TextStyle from "@tiptap/extension-text-style"; @@ -83,6 +85,10 @@ export const CoreEditorExtensionsWithoutProps = [ TableCell, TableRow, CustomMentionWithoutProps(), + Color, + Highlight.configure({ + multicolor: true, + }), ]; export const DocumentEditorExtensionsWithoutProps = [IssueWidgetWithoutProps()]; diff --git a/packages/editor/src/core/extensions/extensions.tsx b/packages/editor/src/core/extensions/extensions.tsx index c6d29b31b27..370dcf7bc89 100644 --- a/packages/editor/src/core/extensions/extensions.tsx +++ b/packages/editor/src/core/extensions/extensions.tsx @@ -1,4 +1,6 @@ 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 TaskItem from "@tiptap/extension-task-item"; import TaskList from "@tiptap/extension-task-list"; @@ -166,4 +168,8 @@ export const CoreEditorExtensions = ({ includeChildren: true, }), CharacterCount, + Color, + Highlight.configure({ + multicolor: true, + }), ]; diff --git a/packages/editor/src/core/extensions/index.ts b/packages/editor/src/core/extensions/index.ts index 9209f9480ff..075f49cefee 100644 --- a/packages/editor/src/core/extensions/index.ts +++ b/packages/editor/src/core/extensions/index.ts @@ -6,6 +6,7 @@ export * from "./custom-list-keymap"; export * from "./image"; export * from "./issue-embed"; export * from "./mentions"; +export * from "./slash-commands"; export * from "./table"; export * from "./typography"; export * from "./core-without-props"; diff --git a/packages/editor/src/core/extensions/read-only-extensions.tsx b/packages/editor/src/core/extensions/read-only-extensions.tsx index 1c0a9add7a2..2a2239dbdc6 100644 --- a/packages/editor/src/core/extensions/read-only-extensions.tsx +++ b/packages/editor/src/core/extensions/read-only-extensions.tsx @@ -1,4 +1,6 @@ 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 TaskList from "@tiptap/extension-task-list"; import TextStyle from "@tiptap/extension-text-style"; @@ -109,5 +111,9 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: { readonly: true, }), CharacterCount, + Color, + Highlight.configure({ + multicolor: true, + }), HeadingListExtension, ]; diff --git a/packages/editor/src/core/extensions/slash-commands.tsx b/packages/editor/src/core/extensions/slash-commands.tsx deleted file mode 100644 index 2be8d89d96b..00000000000 --- a/packages/editor/src/core/extensions/slash-commands.tsx +++ /dev/null @@ -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; -}; - -const Command = Extension.create({ - 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) => - ({ query }: { query: string }) => { - let slashCommands: ISlashCommandItem[] = [ - { - key: "text", - title: "Text", - description: "Just start typing with plain text.", - searchTerms: ["p", "paragraph"], - icon: , - 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: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingOne(editor, range); - }, - }, - { - key: "h2", - title: "Heading 2", - description: "Medium section heading.", - searchTerms: ["subtitle", "medium"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingTwo(editor, range); - }, - }, - { - key: "h3", - title: "Heading 3", - description: "Small section heading.", - searchTerms: ["subtitle", "small"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingThree(editor, range); - }, - }, - { - key: "h4", - title: "Heading 4", - description: "Small section heading.", - searchTerms: ["subtitle", "small"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingFour(editor, range); - }, - }, - { - key: "h5", - title: "Heading 5", - description: "Small section heading.", - searchTerms: ["subtitle", "small"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingFive(editor, range); - }, - }, - { - key: "h6", - title: "Heading 6", - description: "Small section heading.", - searchTerms: ["subtitle", "small"], - icon: , - 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: , - command: ({ editor, range }: CommandProps) => { - toggleTaskList(editor, range); - }, - }, - { - key: "bulleted-list", - title: "Bullet list", - description: "Create a simple bullet list.", - searchTerms: ["unordered", "point"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleBulletList(editor, range); - }, - }, - { - key: "numbered-list", - title: "Numbered list", - description: "Create a list with numbering.", - searchTerms: ["ordered"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleOrderedList(editor, range); - }, - }, - { - key: "table", - title: "Table", - description: "Create a table", - searchTerms: ["table", "cell", "db", "data", "tabular"], - icon: , - command: ({ editor, range }: CommandProps) => { - insertTableCommand(editor, range); - }, - }, - { - key: "quote", - title: "Quote", - description: "Capture a quote.", - searchTerms: ["blockquote"], - icon: , - command: ({ editor, range }: CommandProps) => toggleBlockquote(editor, range), - }, - { - key: "code", - title: "Code", - description: "Capture a code snippet.", - searchTerms: ["codeblock"], - icon: , - command: ({ editor, range }: CommandProps) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), - }, - { - key: "image", - title: "Image", - icon: , - 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: , - 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(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 ( -
- {items.map((item, index) => ( - - ))} -
- ); -}; - -interface CommandListInstance { - onKeyDown: (props: { event: KeyboardEvent }) => boolean; -} - -const renderItems = () => { - let component: ReactRenderer | 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) => - Command.configure({ - suggestion: { - items: getSuggestionItems(additionalOptions), - render: renderItems, - }, - }); diff --git a/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx b/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx new file mode 100644 index 00000000000..5f443ee332c --- /dev/null +++ b/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx @@ -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: , + 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: , + command: ({ editor, range }) => toggleHeadingOne(editor, range), + }, + { + commandKey: "h2", + key: "h2", + title: "Heading 2", + description: "Medium section heading.", + searchTerms: ["subtitle", "medium"], + icon: , + command: ({ editor, range }) => toggleHeadingTwo(editor, range), + }, + { + commandKey: "h3", + key: "h3", + title: "Heading 3", + description: "Small section heading.", + searchTerms: ["subtitle", "small"], + icon: , + command: ({ editor, range }) => toggleHeadingThree(editor, range), + }, + { + commandKey: "h4", + key: "h4", + title: "Heading 4", + description: "Small section heading.", + searchTerms: ["subtitle", "small"], + icon: , + command: ({ editor, range }) => toggleHeadingFour(editor, range), + }, + { + commandKey: "h5", + key: "h5", + title: "Heading 5", + description: "Small section heading.", + searchTerms: ["subtitle", "small"], + icon: , + command: ({ editor, range }) => toggleHeadingFive(editor, range), + }, + { + commandKey: "h6", + key: "h6", + title: "Heading 6", + description: "Small section heading.", + searchTerms: ["subtitle", "small"], + icon: , + 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: , + 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: , + command: ({ editor, range }) => toggleBulletList(editor, range), + }, + { + commandKey: "numbered-list", + key: "numbered-list", + title: "Numbered list", + description: "Create a list with numbering.", + searchTerms: ["ordered"], + icon: , + command: ({ editor, range }) => toggleOrderedList(editor, range), + }, + { + commandKey: "table", + key: "table", + title: "Table", + description: "Create a table", + searchTerms: ["table", "cell", "db", "data", "tabular"], + icon:
, + command: ({ editor, range }) => insertTableCommand(editor, range), + }, + { + commandKey: "quote", + key: "quote", + title: "Quote", + description: "Capture a quote.", + searchTerms: ["blockquote"], + icon: , + command: ({ editor, range }) => toggleBlockquote(editor, range), + }, + { + commandKey: "code", + key: "code", + title: "Code", + description: "Capture a code snippet.", + searchTerms: ["codeblock"], + icon: , + command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), + }, + { + commandKey: "image", + key: "image", + title: "Image", + icon: , + 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: , + 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: ( + + ), + 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: ( + + ), + 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: , + 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: , + 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); + }; diff --git a/packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx b/packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx new file mode 100644 index 00000000000..3a03c3b6a70 --- /dev/null +++ b/packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx @@ -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) => void; + onMouseEnter: () => void; + sectionIndex: number; +}; + +export const CommandMenuItem: React.FC = (props) => { + const { isSelected, item, itemIndex, onClick, onMouseEnter, sectionIndex } = props; + + return ( + + ); +}; diff --git a/packages/editor/src/core/extensions/slash-commands/command-menu.tsx b/packages/editor/src/core/extensions/slash-commands/command-menu.tsx new file mode 100644 index 00000000000..977f688286e --- /dev/null +++ b/packages/editor/src/core/extensions/slash-commands/command-menu.tsx @@ -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(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 ( +
+ {sections.map((section, sectionIndex) => ( +
+ {section.title &&
{section.title}
} +
+ {section.items.map((item, itemIndex) => ( + { + e.stopPropagation(); + selectItem(sectionIndex, itemIndex); + }} + onMouseEnter={() => + setSelectedIndex({ + section: sectionIndex, + item: itemIndex, + }) + } + sectionIndex={sectionIndex} + /> + ))} +
+
+ ))} +
+ ); +}; diff --git a/packages/editor/src/core/extensions/slash-commands/index.ts b/packages/editor/src/core/extensions/slash-commands/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/packages/editor/src/core/extensions/slash-commands/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/packages/editor/src/core/extensions/slash-commands/root.tsx b/packages/editor/src/core/extensions/slash-commands/root.tsx new file mode 100644 index 00000000000..ac88f20d3ac --- /dev/null +++ b/packages/editor/src/core/extensions/slash-commands/root.tsx @@ -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; +}; + +const Command = Extension.create({ + 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 | 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, + }, + }); diff --git a/packages/editor/src/core/helpers/editor-commands.ts b/packages/editor/src/core/helpers/editor-commands.ts index 66be05bb261..fb63a6fbf23 100644 --- a/packages/editor/src/core/helpers/editor-commands.ts +++ b/packages/editor/src/core/helpers/editor-commands.ts @@ -154,3 +154,42 @@ export const unsetLinkEditor = (editor: Editor) => { export const setLinkEditor = (editor: Editor, url: string) => { 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(); + } + } +}; diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index 65e36c01ae6..0edb6ca50bd 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -136,7 +136,8 @@ export const useEditor = (props: CustomEditorProps) => { insertContentAtSavedSelection(editorRef, content, savedSelection); } }, - executeMenuItemCommand: (itemKey: TEditorCommands) => { + executeMenuItemCommand: (props) => { + const { itemKey } = props; const editorItems = getEditorMenuItems(editorRef.current); const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey); @@ -145,6 +146,8 @@ export const useEditor = (props: CustomEditorProps) => { if (item) { if (item.key === "image") { item.command(savedSelectionRef.current); + } else if (itemKey === "text-color" || itemKey === "background-color") { + item.command(props.color); } else { item.command(); } @@ -152,12 +155,19 @@ export const useEditor = (props: CustomEditorProps) => { console.warn(`No command found for item: ${itemKey}`); } }, - isMenuItemActive: (itemName: TEditorCommands): boolean => { + isMenuItemActive: (props) => { + const { itemKey } = props; const editorItems = getEditorMenuItems(editorRef.current); - const getEditorMenuItem = (itemName: TEditorCommands) => editorItems.find((item) => item.key === itemName); - const item = getEditorMenuItem(itemName); - return item ? item.isActive() : false; + const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey); + const item = getEditorMenuItem(itemKey); + 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) => { // Subscribe to update event emitted from headers extension diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index 3624fa046ce..c833cb74920 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -6,14 +6,15 @@ import { IMentionHighlight, IMentionSuggestion, TAIHandler, + TColorEditorCommands, TDisplayConfig, TEditorCommands, TEmbedConfig, TExtensions, TFileHandler, + TNonColorEditorCommands, TServerHandler, } from "@/types"; - // editor refs export type EditorReadOnlyRefApi = { getMarkDown: () => string; @@ -36,8 +37,26 @@ export type EditorReadOnlyRefApi = { export interface EditorRefApi extends EditorReadOnlyRefApi { setEditorValueAtCursorPosition: (content: string) => void; - executeMenuItemCommand: (itemKey: TEditorCommands) => void; - isMenuItemActive: (itemKey: TEditorCommands) => boolean; + executeMenuItemCommand: ( + props: + | { + itemKey: TNonColorEditorCommands; + } + | { + itemKey: TColorEditorCommands; + color: string | undefined; + } + ) => void; + isMenuItemActive: ( + props: + | { + itemKey: TNonColorEditorCommands; + } + | { + itemKey: TColorEditorCommands; + color: string | undefined; + } + ) => boolean; onStateChange: (callback: () => void) => () => void; setFocusAtPosition: (position: number) => void; isEditorReadyToDiscard: () => boolean; diff --git a/packages/editor/src/core/types/slash-commands-suggestion.ts b/packages/editor/src/core/types/slash-commands-suggestion.ts index 3cb9d76b0ea..ce3408a34f0 100644 --- a/packages/editor/src/core/types/slash-commands-suggestion.ts +++ b/packages/editor/src/core/types/slash-commands-suggestion.ts @@ -1,4 +1,4 @@ -import { ReactNode } from "react"; +import { CSSProperties } from "react"; import { Editor, Range } from "@tiptap/core"; export type TEditorCommands = @@ -21,7 +21,12 @@ export type TEditorCommands = | "table" | "image" | "divider" - | "issue-embed"; + | "issue-embed" + | "text-color" + | "background-color"; + +export type TColorEditorCommands = Extract; +export type TNonColorEditorCommands = Exclude; export type CommandProps = { editor: Editor; @@ -29,10 +34,12 @@ export type CommandProps = { }; export type ISlashCommandItem = { - key: TEditorCommands; + commandKey: TEditorCommands; + key: string; title: string; description: string; searchTerms: string[]; - icon: ReactNode; + icon: React.ReactNode; + iconContainerStyle?: CSSProperties; command: ({ editor, range }: CommandProps) => void; }; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index fc9fe1ac603..c65f43dd4fb 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -18,6 +18,9 @@ export { export { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection"; +// constants +export * from "@/constants/common"; + // helpers export * from "@/helpers/common"; export * from "@/helpers/editor-commands"; diff --git a/space/core/components/editor/lite-text-editor.tsx b/space/core/components/editor/lite-text-editor.tsx index 186f44a1011..86b143f9ded 100644 --- a/space/core/components/editor/lite-text-editor.tsx +++ b/space/core/components/editor/lite-text-editor.tsx @@ -1,6 +1,6 @@ import React from "react"; // editor -import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor"; +import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef, TNonColorEditorCommands } from "@plane/editor"; // components import { IssueCommentToolbar } from "@/components/editor"; // helpers @@ -56,7 +56,9 @@ export const LiteTextEditor = React.forwardRef { if (isMutableRefObject(ref)) { - ref.current?.executeMenuItemCommand(key); + ref.current?.executeMenuItemCommand({ + itemKey: key as TNonColorEditorCommands, + }); } }} isSubmitting={isSubmitting} diff --git a/space/core/components/editor/toolbar.tsx b/space/core/components/editor/toolbar.tsx index 45e94c2d9a5..beccc8cb763 100644 --- a/space/core/components/editor/toolbar.tsx +++ b/space/core/components/editor/toolbar.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState, useCallback } from "react"; // editor -import { EditorRefApi, TEditorCommands } from "@plane/editor"; +import { EditorRefApi, TEditorCommands, TNonColorEditorCommands } from "@plane/editor"; // ui import { Button, Tooltip } from "@plane/ui"; // constants @@ -34,7 +34,9 @@ export const IssueCommentToolbar: React.FC = (props) => { .flat() .forEach((item) => { // 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); } diff --git a/web/core/components/editor/lite-text-editor/lite-text-editor.tsx b/web/core/components/editor/lite-text-editor/lite-text-editor.tsx index 8036e4c8d4e..f8e1f3bde22 100644 --- a/web/core/components/editor/lite-text-editor/lite-text-editor.tsx +++ b/web/core/components/editor/lite-text-editor/lite-text-editor.tsx @@ -1,6 +1,6 @@ import React from "react"; // editor -import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor"; +import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef, TNonColorEditorCommands } from "@plane/editor"; // types import { IUserLite } from "@plane/types"; // components @@ -87,7 +87,9 @@ export const LiteTextEditor = React.forwardRef { if (isMutableRefObject(ref)) { - ref.current?.executeMenuItemCommand(key); + ref.current?.executeMenuItemCommand({ + itemKey: key as TNonColorEditorCommands, + }); } }} handleAccessChange={handleAccessChange} diff --git a/web/core/components/editor/lite-text-editor/toolbar.tsx b/web/core/components/editor/lite-text-editor/toolbar.tsx index 58eef6f13b5..ecf8c3283c8 100644 --- a/web/core/components/editor/lite-text-editor/toolbar.tsx +++ b/web/core/components/editor/lite-text-editor/toolbar.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useState, useCallback } from "react"; import { Globe2, Lock, LucideIcon } from "lucide-react"; // editor -import { EditorRefApi, TEditorCommands } from "@plane/editor"; +import { EditorRefApi, TEditorCommands, TNonColorEditorCommands } from "@plane/editor"; // ui import { Button, Tooltip } from "@plane/ui"; // constants @@ -69,7 +69,9 @@ export const IssueCommentToolbar: React.FC = (props) => { .flat() .forEach((item) => { // 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); } diff --git a/web/core/components/pages/editor/header/color-dropdown.tsx b/web/core/components/pages/editor/header/color-dropdown.tsx new file mode 100644 index 00000000000..33d7c534fb9 --- /dev/null +++ b/web/core/components/pages/editor/header/color-dropdown.tsx @@ -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 = 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 ( + + + cn("h-full", { + "outline-none": open, + }) + } + > + {({ open }) => ( + + Color + + + + + )} + + +
+

Text colors

+
+ {COLORS_LIST.map((color) => ( + +
+
+
+

Background colors

+
+ {COLORS_LIST.map((color) => ( + +
+
+
+
+ ); +}); diff --git a/web/core/components/pages/editor/header/index.ts b/web/core/components/pages/editor/header/index.ts index 219ed44d87f..d87f5d11946 100644 --- a/web/core/components/pages/editor/header/index.ts +++ b/web/core/components/pages/editor/header/index.ts @@ -1,3 +1,4 @@ +export * from "./color-dropdown"; export * from "./extra-options"; export * from "./info-popover"; export * from "./options-dropdown"; diff --git a/web/core/components/pages/editor/header/toolbar.tsx b/web/core/components/pages/editor/header/toolbar.tsx index 65d484ef152..447616b532f 100644 --- a/web/core/components/pages/editor/header/toolbar.tsx +++ b/web/core/components/pages/editor/header/toolbar.tsx @@ -3,9 +3,11 @@ import React, { useEffect, useState, useCallback } from "react"; import { Check, ChevronDown } from "lucide-react"; // editor -import { EditorRefApi, TEditorCommands } from "@plane/editor"; +import { EditorRefApi, TNonColorEditorCommands } from "@plane/editor"; // ui import { CustomMenu, Tooltip } from "@plane/ui"; +// components +import { ColorDropdown } from "@/components/pages"; // constants import { TOOLBAR_ITEMS, TYPOGRAPHY_ITEMS, ToolbarMenuItem } from "@/constants/editor"; // helpers @@ -18,7 +20,7 @@ type Props = { type ToolbarButtonProps = { item: ToolbarMenuItem; isActive: boolean; - executeCommand: (commandKey: TEditorCommands) => void; + executeCommand: EditorRefApi["executeMenuItemCommand"]; }; const ToolbarButton: React.FC = React.memo((props) => { @@ -36,7 +38,11 @@ const ToolbarButton: React.FC = React.memo((props) => {