diff --git a/packages/editor/package.json b/packages/editor/package.json index c52c630c8c9..067be0145dd 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -48,6 +48,7 @@ "@tiptap/extension-placeholder": "^2.3.0", "@tiptap/extension-task-item": "^2.1.13", "@tiptap/extension-task-list": "^2.1.13", + "@tiptap/extension-text-align": "^2.8.0", "@tiptap/extension-text-style": "^2.7.1", "@tiptap/extension-underline": "^2.1.13", "@tiptap/pm": "^2.1.13", diff --git a/packages/editor/src/core/components/menus/bubble-menu/alignment-selector.tsx b/packages/editor/src/core/components/menus/bubble-menu/alignment-selector.tsx new file mode 100644 index 00000000000..e3ccc6cf62b --- /dev/null +++ b/packages/editor/src/core/components/menus/bubble-menu/alignment-selector.tsx @@ -0,0 +1,91 @@ +import { Editor } from "@tiptap/core"; +import { AlignCenter, AlignLeft, AlignRight, LucideIcon } from "lucide-react"; +// components +import { TextAlignItem } from "@/components/menus"; +// helpers +import { cn } from "@/helpers/common"; +// types +import { TEditorCommands } from "@/types"; + +type Props = { + editor: Editor; + onClose: () => void; +}; + +export const TextAlignmentSelector: React.FC = (props) => { + const { editor, onClose } = props; + + const menuItem = TextAlignItem(editor); + + const textAlignmentOptions: { + itemKey: TEditorCommands; + renderKey: string; + icon: LucideIcon; + command: () => void; + isActive: () => boolean; + }[] = [ + { + itemKey: "text-align", + renderKey: "text-align-left", + icon: AlignLeft, + command: () => + menuItem.command({ + alignment: "left", + }), + isActive: () => + menuItem.isActive({ + alignment: "left", + }), + }, + { + itemKey: "text-align", + renderKey: "text-align-center", + icon: AlignCenter, + command: () => + menuItem.command({ + alignment: "center", + }), + isActive: () => + menuItem.isActive({ + alignment: "center", + }), + }, + { + itemKey: "text-align", + renderKey: "text-align-right", + icon: AlignRight, + command: () => + menuItem.command({ + alignment: "right", + }), + isActive: () => + menuItem.isActive({ + alignment: "right", + }), + }, + ]; + + return ( +
+ {textAlignmentOptions.map((item) => ( + + ))} +
+ ); +}; 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 index b48070775e3..bc7f5a56f12 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/color-selector.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/color-selector.tsx @@ -16,8 +16,8 @@ type Props = { export const BubbleMenuColorSelector: FC = (props) => { const { editor, isOpen, setIsOpen } = props; - const activeTextColor = COLORS_LIST.find((c) => TextColorItem(editor).isActive(c.key)); - const activeBackgroundColor = COLORS_LIST.find((c) => BackgroundColorItem(editor).isActive(c.key)); + const activeTextColor = COLORS_LIST.find((c) => TextColorItem(editor).isActive({ color: c.key })); + const activeBackgroundColor = COLORS_LIST.find((c) => BackgroundColorItem(editor).isActive({ color: c.key })); return (
@@ -64,7 +64,7 @@ export const BubbleMenuColorSelector: FC = (props) => { style={{ backgroundColor: color.textColor, }} - onClick={() => TextColorItem(editor).command(color.key)} + onClick={() => TextColorItem(editor).command({ color: color.key })} /> ))}
- {items.map((item) => ( + {basicFormattingOptions.map((item) => ( ))}
+ { + const editor = props.editor as Editor; + if (!editor) return; + const pos = editor.state.selection.to; + editor.commands.setTextSelection(pos ?? 0); + }} + /> )} diff --git a/packages/editor/src/core/components/menus/menu-items.ts b/packages/editor/src/core/components/menus/menu-items.ts index 0ece455ed25..5e987fca6d5 100644 --- a/packages/editor/src/core/components/menus/menu-items.ts +++ b/packages/editor/src/core/components/menus/menu-items.ts @@ -1,4 +1,3 @@ -import { Selection } from "@tiptap/pm/state"; import { Editor } from "@tiptap/react"; import { BoldIcon, @@ -22,6 +21,7 @@ import { LucideIcon, MinusSquare, Palette, + AlignCenter, } from "lucide-react"; // helpers import { @@ -29,6 +29,7 @@ import { insertImage, insertTableCommand, setText, + setTextAlign, toggleBackgroundColor, toggleBlockquote, toggleBold, @@ -48,24 +49,20 @@ import { toggleUnderline, } from "@/helpers/editor-commands"; // types -import { TColorEditorCommands, TNonColorEditorCommands } from "@/types"; +import { TCommandWithProps, TEditorCommands } from "@/types"; -export type EditorMenuItem = { +type isActiveFunction = (params?: TCommandWithProps) => boolean; +type commandFunction = (params?: TCommandWithProps) => void; + +export type EditorMenuItem = { + key: T; name: string; - command: (...args: any) => void; + command: commandFunction; icon: LucideIcon; -} & ( - | { - key: TNonColorEditorCommands; - isActive: () => boolean; - } - | { - key: TColorEditorCommands; - isActive: (color: string | undefined) => boolean; - } -); + isActive: isActiveFunction; +}; -export const TextItem = (editor: Editor): EditorMenuItem => ({ +export const TextItem = (editor: Editor): EditorMenuItem<"text"> => ({ key: "text", name: "Text", isActive: () => editor.isActive("paragraph"), @@ -73,7 +70,7 @@ export const TextItem = (editor: Editor): EditorMenuItem => ({ icon: CaseSensitive, }); -export const HeadingOneItem = (editor: Editor): EditorMenuItem => ({ +export const HeadingOneItem = (editor: Editor): EditorMenuItem<"h1"> => ({ key: "h1", name: "Heading 1", isActive: () => editor.isActive("heading", { level: 1 }), @@ -81,7 +78,7 @@ export const HeadingOneItem = (editor: Editor): EditorMenuItem => ({ icon: Heading1, }); -export const HeadingTwoItem = (editor: Editor): EditorMenuItem => ({ +export const HeadingTwoItem = (editor: Editor): EditorMenuItem<"h2"> => ({ key: "h2", name: "Heading 2", isActive: () => editor.isActive("heading", { level: 2 }), @@ -89,7 +86,7 @@ export const HeadingTwoItem = (editor: Editor): EditorMenuItem => ({ icon: Heading2, }); -export const HeadingThreeItem = (editor: Editor): EditorMenuItem => ({ +export const HeadingThreeItem = (editor: Editor): EditorMenuItem<"h3"> => ({ key: "h3", name: "Heading 3", isActive: () => editor.isActive("heading", { level: 3 }), @@ -97,7 +94,7 @@ export const HeadingThreeItem = (editor: Editor): EditorMenuItem => ({ icon: Heading3, }); -export const HeadingFourItem = (editor: Editor): EditorMenuItem => ({ +export const HeadingFourItem = (editor: Editor): EditorMenuItem<"h4"> => ({ key: "h4", name: "Heading 4", isActive: () => editor.isActive("heading", { level: 4 }), @@ -105,7 +102,7 @@ export const HeadingFourItem = (editor: Editor): EditorMenuItem => ({ icon: Heading4, }); -export const HeadingFiveItem = (editor: Editor): EditorMenuItem => ({ +export const HeadingFiveItem = (editor: Editor): EditorMenuItem<"h5"> => ({ key: "h5", name: "Heading 5", isActive: () => editor.isActive("heading", { level: 5 }), @@ -113,7 +110,7 @@ export const HeadingFiveItem = (editor: Editor): EditorMenuItem => ({ icon: Heading5, }); -export const HeadingSixItem = (editor: Editor): EditorMenuItem => ({ +export const HeadingSixItem = (editor: Editor): EditorMenuItem<"h6"> => ({ key: "h6", name: "Heading 6", isActive: () => editor.isActive("heading", { level: 6 }), @@ -121,7 +118,7 @@ export const HeadingSixItem = (editor: Editor): EditorMenuItem => ({ icon: Heading6, }); -export const BoldItem = (editor: Editor): EditorMenuItem => ({ +export const BoldItem = (editor: Editor): EditorMenuItem<"bold"> => ({ key: "bold", name: "Bold", isActive: () => editor?.isActive("bold"), @@ -129,7 +126,7 @@ export const BoldItem = (editor: Editor): EditorMenuItem => ({ icon: BoldIcon, }); -export const ItalicItem = (editor: Editor): EditorMenuItem => ({ +export const ItalicItem = (editor: Editor): EditorMenuItem<"italic"> => ({ key: "italic", name: "Italic", isActive: () => editor?.isActive("italic"), @@ -137,7 +134,7 @@ export const ItalicItem = (editor: Editor): EditorMenuItem => ({ icon: ItalicIcon, }); -export const UnderLineItem = (editor: Editor): EditorMenuItem => ({ +export const UnderLineItem = (editor: Editor): EditorMenuItem<"underline"> => ({ key: "underline", name: "Underline", isActive: () => editor?.isActive("underline"), @@ -145,7 +142,7 @@ export const UnderLineItem = (editor: Editor): EditorMenuItem => ({ icon: UnderlineIcon, }); -export const StrikeThroughItem = (editor: Editor): EditorMenuItem => ({ +export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strikethrough"> => ({ key: "strikethrough", name: "Strikethrough", isActive: () => editor?.isActive("strike"), @@ -153,7 +150,7 @@ export const StrikeThroughItem = (editor: Editor): EditorMenuItem => ({ icon: StrikethroughIcon, }); -export const BulletListItem = (editor: Editor): EditorMenuItem => ({ +export const BulletListItem = (editor: Editor): EditorMenuItem<"bulleted-list"> => ({ key: "bulleted-list", name: "Bulleted list", isActive: () => editor?.isActive("bulletList"), @@ -161,7 +158,7 @@ export const BulletListItem = (editor: Editor): EditorMenuItem => ({ icon: ListIcon, }); -export const NumberedListItem = (editor: Editor): EditorMenuItem => ({ +export const NumberedListItem = (editor: Editor): EditorMenuItem<"numbered-list"> => ({ key: "numbered-list", name: "Numbered list", isActive: () => editor?.isActive("orderedList"), @@ -169,7 +166,7 @@ export const NumberedListItem = (editor: Editor): EditorMenuItem => ({ icon: ListOrderedIcon, }); -export const TodoListItem = (editor: Editor): EditorMenuItem => ({ +export const TodoListItem = (editor: Editor): EditorMenuItem<"to-do-list"> => ({ key: "to-do-list", name: "To-do list", isActive: () => editor.isActive("taskItem"), @@ -177,7 +174,7 @@ export const TodoListItem = (editor: Editor): EditorMenuItem => ({ icon: CheckSquare, }); -export const QuoteItem = (editor: Editor): EditorMenuItem => ({ +export const QuoteItem = (editor: Editor): EditorMenuItem<"quote"> => ({ key: "quote", name: "Quote", isActive: () => editor?.isActive("blockquote"), @@ -185,7 +182,7 @@ export const QuoteItem = (editor: Editor): EditorMenuItem => ({ icon: TextQuote, }); -export const CodeItem = (editor: Editor): EditorMenuItem => ({ +export const CodeItem = (editor: Editor): EditorMenuItem<"code"> => ({ key: "code", name: "Code", isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"), @@ -193,7 +190,7 @@ export const CodeItem = (editor: Editor): EditorMenuItem => ({ icon: CodeIcon, }); -export const TableItem = (editor: Editor): EditorMenuItem => ({ +export const TableItem = (editor: Editor): EditorMenuItem<"table"> => ({ key: "table", name: "Table", isActive: () => editor?.isActive("table"), @@ -201,14 +198,14 @@ export const TableItem = (editor: Editor): EditorMenuItem => ({ icon: TableIcon, }); -export const ImageItem = (editor: Editor) => - ({ - key: "image", - name: "Image", - isActive: () => editor?.isActive("image") || editor?.isActive("imageComponent"), - command: (savedSelection: Selection | null) => insertImage({ editor, event: "insert", pos: savedSelection?.from }), - icon: ImageIcon, - }) as const; +export const ImageItem = (editor: Editor): EditorMenuItem<"image"> => ({ + key: "image", + name: "Image", + isActive: () => editor?.isActive("image") || editor?.isActive("imageComponent"), + command: ({ savedSelection }) => + insertImage({ editor, event: "insert", pos: savedSelection?.from ?? editor.state.selection.from }), + icon: ImageIcon, +}); export const HorizontalRuleItem = (editor: Editor) => ({ @@ -219,23 +216,31 @@ export const HorizontalRuleItem = (editor: Editor) => icon: MinusSquare, }) as const; -export const TextColorItem = (editor: Editor): EditorMenuItem => ({ +export const TextColorItem = (editor: Editor): EditorMenuItem<"text-color"> => ({ key: "text-color", name: "Color", - isActive: (color) => editor.isActive("customColor", { color }), - command: (color: string) => toggleTextColor(color, editor), + isActive: ({ color }) => editor.isActive("customColor", { color }), + command: ({ color }) => toggleTextColor(color, editor), icon: Palette, }); -export const BackgroundColorItem = (editor: Editor): EditorMenuItem => ({ +export const BackgroundColorItem = (editor: Editor): EditorMenuItem<"background-color"> => ({ key: "background-color", name: "Background color", - isActive: (color) => editor.isActive("customColor", { backgroundColor: color }), - command: (color: string) => toggleBackgroundColor(color, editor), + isActive: ({ color }) => editor.isActive("customColor", { backgroundColor: color }), + command: ({ color }) => toggleBackgroundColor(color, editor), icon: Palette, }); -export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem[] => { +export const TextAlignItem = (editor: Editor): EditorMenuItem<"text-align"> => ({ + key: "text-align", + name: "Text align", + isActive: ({ alignment }) => editor.isActive({ textAlign: alignment }), + command: ({ alignment }) => setTextAlign(alignment, editor), + icon: AlignCenter, +}); + +export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem[] => { if (!editor) return []; return [ @@ -260,5 +265,6 @@ export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem[] => { HorizontalRuleItem(editor), TextColorItem(editor), BackgroundColorItem(editor), + TextAlignItem(editor), ]; }; diff --git a/packages/editor/src/core/extensions/core-without-props.ts b/packages/editor/src/core/extensions/core-without-props.ts index 39a243f88a9..abceea4ff53 100644 --- a/packages/editor/src/core/extensions/core-without-props.ts +++ b/packages/editor/src/core/extensions/core-without-props.ts @@ -16,6 +16,7 @@ import { IssueWidgetWithoutProps } from "./issue-embed/issue-embed-without-props import { CustomMentionWithoutProps } from "./mentions/mentions-without-props"; import { CustomQuoteExtension } from "./quote"; import { TableHeader, TableCell, TableRow, Table } from "./table"; +import { CustomTextAlignExtension } from "./text-align"; import { CustomCalloutExtensionConfig } from "./callout/extension-config"; import { CustomColorExtension } from "./custom-color"; @@ -85,6 +86,7 @@ export const CoreEditorExtensionsWithoutProps = [ TableCell, TableRow, CustomMentionWithoutProps(), + CustomTextAlignExtension, CustomCalloutExtensionConfig, CustomColorExtension, ]; diff --git a/packages/editor/src/core/extensions/extensions.tsx b/packages/editor/src/core/extensions/extensions.tsx index 24a372b4aaa..3907e3b9d2c 100644 --- a/packages/editor/src/core/extensions/extensions.tsx +++ b/packages/editor/src/core/extensions/extensions.tsx @@ -19,6 +19,7 @@ import { CustomLinkExtension, CustomMention, CustomQuoteExtension, + CustomTextAlignExtension, CustomTypographyExtension, DropHandlerExtension, ImageExtension, @@ -158,6 +159,7 @@ export const CoreEditorExtensions = (args: TArguments) => { includeChildren: true, }), CharacterCount, + CustomTextAlignExtension, CustomCalloutExtension, CustomColorExtension, ]; diff --git a/packages/editor/src/core/extensions/index.ts b/packages/editor/src/core/extensions/index.ts index dbd529d8c15..d1fa0ce6db9 100644 --- a/packages/editor/src/core/extensions/index.ts +++ b/packages/editor/src/core/extensions/index.ts @@ -16,10 +16,10 @@ export * from "./custom-color"; export * from "./drop"; export * from "./enter-key-extension"; export * from "./extensions"; +export * from "./headers"; export * from "./horizontal-rule"; export * from "./keymap"; export * from "./quote"; export * from "./read-only-extensions"; export * from "./side-menu"; -export * from "./slash-commands"; -export * from "./headers"; +export * from "./text-align"; diff --git a/packages/editor/src/core/extensions/read-only-extensions.tsx b/packages/editor/src/core/extensions/read-only-extensions.tsx index 096cefbae65..31ae1d90a14 100644 --- a/packages/editor/src/core/extensions/read-only-extensions.tsx +++ b/packages/editor/src/core/extensions/read-only-extensions.tsx @@ -21,6 +21,7 @@ import { CustomMention, HeadingListExtension, CustomReadOnlyImageExtension, + CustomTextAlignExtension, CustomCalloutReadOnlyExtension, CustomColorExtension, } from "@/extensions"; @@ -125,6 +126,7 @@ export const CoreReadOnlyEditorExtensions = (props: Props) => { CharacterCount, CustomColorExtension, HeadingListExtension, + CustomTextAlignExtension, CustomCalloutReadOnlyExtension, ]; }; diff --git a/packages/editor/src/core/extensions/slash-commands/command-menu.tsx b/packages/editor/src/core/extensions/slash-commands/command-menu.tsx index c6363bc51c7..d6148b69aef 100644 --- a/packages/editor/src/core/extensions/slash-commands/command-menu.tsx +++ b/packages/editor/src/core/extensions/slash-commands/command-menu.tsx @@ -3,12 +3,12 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react import { TSlashCommandSection } from "./command-items-list"; import { CommandMenuItem } from "./command-menu-item"; -type Props = { +export type SlashCommandsMenuProps = { items: TSlashCommandSection[]; command: any; }; -export const SlashCommandsMenu = (props: Props) => { +export const SlashCommandsMenu = (props: SlashCommandsMenuProps) => { const { items: sections, command } = props; // states const [selectedIndex, setSelectedIndex] = useState({ diff --git a/packages/editor/src/core/extensions/slash-commands/root.tsx b/packages/editor/src/core/extensions/slash-commands/root.tsx index df70820dcf5..a99cbc5f903 100644 --- a/packages/editor/src/core/extensions/slash-commands/root.tsx +++ b/packages/editor/src/core/extensions/slash-commands/root.tsx @@ -6,7 +6,7 @@ import tippy from "tippy.js"; import { ISlashCommandItem } from "@/types"; // components import { getSlashCommandFilteredSections } from "./command-items-list"; -import { SlashCommandsMenu } from "./command-menu"; +import { SlashCommandsMenu, SlashCommandsMenuProps } from "./command-menu"; export type SlashCommandOptions = { suggestion: Omit; @@ -55,7 +55,7 @@ interface CommandListInstance { } const renderItems = () => { - let component: ReactRenderer | null = null; + let component: ReactRenderer | null = null; let popup: any | null = null; return { onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { diff --git a/packages/editor/src/core/extensions/text-align.ts b/packages/editor/src/core/extensions/text-align.ts new file mode 100644 index 00000000000..bfe62f6c04b --- /dev/null +++ b/packages/editor/src/core/extensions/text-align.ts @@ -0,0 +1,8 @@ +import TextAlign from "@tiptap/extension-text-align"; + +export type TTextAlign = "left" | "center" | "right"; + +export const CustomTextAlignExtension = TextAlign.configure({ + alignments: ["left", "center", "right"], + types: ["heading", "paragraph"], +}); diff --git a/packages/editor/src/core/helpers/editor-commands.ts b/packages/editor/src/core/helpers/editor-commands.ts index efa19e38765..ec593d53676 100644 --- a/packages/editor/src/core/helpers/editor-commands.ts +++ b/packages/editor/src/core/helpers/editor-commands.ts @@ -181,10 +181,14 @@ export const toggleBackgroundColor = (color: string | undefined, editor: Editor, } }; +export const setTextAlign = (alignment: string, editor: Editor) => { + editor.chain().focus().setTextAlign(alignment).run(); +}; + export const insertHorizontalRule = (editor: Editor, range?: Range) => { if (range) editor.chain().focus().deleteRange(range).setHorizontalRule().run(); else editor.chain().focus().setHorizontalRule().run(); -} +}; export const insertCallout = (editor: Editor, range?: Range) => { if (range) editor.chain().focus().deleteRange(range).insertCallout().run(); else editor.chain().focus().insertCallout().run(); diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index 67d7799660f..eef72797cee 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -6,7 +6,7 @@ import { EditorProps } from "@tiptap/pm/view"; import { useEditor as useTiptapEditor, Editor } from "@tiptap/react"; import * as Y from "yjs"; // components -import { getEditorMenuItems } from "@/components/menus"; +import { EditorMenuItem, getEditorMenuItems } from "@/components/menus"; // extensions import { CoreEditorExtensions } from "@/extensions"; // helpers @@ -155,11 +155,11 @@ export const useEditor = (props: CustomEditorProps) => { const item = getEditorMenuItem(itemKey); if (item) { if (item.key === "image") { - item.command(savedSelectionRef.current); - } else if (itemKey === "text-color" || itemKey === "background-color") { - item.command(props.color); + (item as EditorMenuItem<"image">).command({ + savedSelection: savedSelectionRef.current, + }); } else { - item.command(); + item.command(props); } } else { console.warn(`No command found for item: ${itemKey}`); @@ -173,11 +173,7 @@ export const useEditor = (props: CustomEditorProps) => { const item = getEditorMenuItem(itemKey); if (!item) return false; - if (itemKey === "text-color" || itemKey === "background-color") { - return item.isActive(props.color); - } else { - return item.isActive(""); - } + return item.isActive(props); }, 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 97f22e7723e..53aae1f265d 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -1,4 +1,5 @@ import { JSONContent } from "@tiptap/core"; +import { Selection } from "@tiptap/pm/state"; // helpers import { IMarking } from "@/helpers/scroll-to-node"; // types @@ -6,14 +7,64 @@ import { IMentionHighlight, IMentionSuggestion, TAIHandler, - TColorEditorCommands, TDisplayConfig, TEmbedConfig, TExtensions, TFileHandler, - TNonColorEditorCommands, TServerHandler, } from "@/types"; +import { TTextAlign } from "@/extensions"; + +export type TEditorCommands = + | "text" + | "h1" + | "h2" + | "h3" + | "h4" + | "h5" + | "h6" + | "bold" + | "italic" + | "underline" + | "strikethrough" + | "bulleted-list" + | "numbered-list" + | "to-do-list" + | "quote" + | "code" + | "table" + | "image" + | "divider" + | "issue-embed" + | "text-color" + | "background-color" + | "text-align" + | "callout"; + +export type TCommandExtraProps = { + image: { + savedSelection: Selection | null; + }; + "text-color": { + color: string | undefined; + }; + "background-color": { + color: string | undefined; + }; + "text-align": { + alignment: TTextAlign; + }; +}; + +// Create a utility type that maps a command to its extra props or an empty object if none are defined +export type TCommandWithProps = T extends keyof TCommandExtraProps + ? TCommandExtraProps[T] // If the command has extra props, include them + : object; // Otherwise, just return the command type with no extra props + +type TCommandWithPropsWithItemKey = T extends keyof TCommandExtraProps + ? { itemKey: T } & TCommandExtraProps[T] + : { itemKey: T }; + // editor refs export type EditorReadOnlyRefApi = { getMarkDown: () => string; @@ -39,26 +90,8 @@ export interface EditorRefApi extends EditorReadOnlyRefApi { scrollToNodeViaDOMCoordinates: (behavior?: ScrollBehavior, position?: number) => void; getCurrentCursorPosition: () => number | undefined; setEditorValueAtCursorPosition: (content: string) => void; - executeMenuItemCommand: ( - props: - | { - itemKey: TNonColorEditorCommands; - } - | { - itemKey: TColorEditorCommands; - color: string | undefined; - } - ) => void; - isMenuItemActive: ( - props: - | { - itemKey: TNonColorEditorCommands; - } - | { - itemKey: TColorEditorCommands; - color: string | undefined; - } - ) => boolean; + executeMenuItemCommand: (props: TCommandWithPropsWithItemKey) => void; + isMenuItemActive: (props: TCommandWithPropsWithItemKey) => 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 122231111e0..91c93203af4 100644 --- a/packages/editor/src/core/types/slash-commands-suggestion.ts +++ b/packages/editor/src/core/types/slash-commands-suggestion.ts @@ -1,33 +1,7 @@ import { CSSProperties } from "react"; import { Editor, Range } from "@tiptap/core"; - -export type TEditorCommands = - | "text" - | "h1" - | "h2" - | "h3" - | "h4" - | "h5" - | "h6" - | "bold" - | "italic" - | "underline" - | "strikethrough" - | "bulleted-list" - | "numbered-list" - | "to-do-list" - | "quote" - | "code" - | "table" - | "image" - | "divider" - | "issue-embed" - | "text-color" - | "background-color" - | "callout"; - -export type TColorEditorCommands = Extract; -export type TNonColorEditorCommands = Exclude; +// types +import { TEditorCommands } from "@/types"; export type CommandProps = { editor: Editor; diff --git a/space/core/components/editor/lite-text-editor.tsx b/space/core/components/editor/lite-text-editor.tsx index 4cd6d82e879..0e3f34293b5 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, TNonColorEditorCommands } from "@plane/editor"; +import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor"; // components import { IssueCommentToolbar } from "@/components/editor"; // helpers @@ -30,11 +30,12 @@ export const LiteTextEditor = React.forwardRef(ref: React.ForwardedRef): ref is React.MutableRefObject { return !!ref && typeof ref === "object" && "current" in ref; } + // derived values const isEmpty = isCommentEmpty(props.initialValue); + const editorRef = isMutableRefObject(ref) ? ref.current : null; return (
@@ -54,18 +55,19 @@ export const LiteTextEditor = React.forwardRef { - if (isMutableRefObject(ref)) { - ref.current?.executeMenuItemCommand({ - itemKey: key as TNonColorEditorCommands, - }); - } + executeCommand={(item) => { + // TODO: update this while toolbar homogenization + // @ts-expect-error type mismatch here + editorRef?.executeMenuItemCommand({ + itemKey: item.itemKey, + ...item.extraProps, + }); }} isSubmitting={isSubmitting} showSubmitButton={showSubmitButton} handleSubmit={(e) => rest.onEnterKeyPress?.(e)} isCommentEmpty={isEmpty} - editorRef={isMutableRefObject(ref) ? ref : null} + editorRef={editorRef} />
); diff --git a/space/core/components/editor/toolbar.tsx b/space/core/components/editor/toolbar.tsx index beccc8cb763..4593aaf6539 100644 --- a/space/core/components/editor/toolbar.tsx +++ b/space/core/components/editor/toolbar.tsx @@ -2,21 +2,21 @@ import React, { useEffect, useState, useCallback } from "react"; // editor -import { EditorRefApi, TEditorCommands, TNonColorEditorCommands } from "@plane/editor"; +import { EditorRefApi } from "@plane/editor"; // ui import { Button, Tooltip } from "@plane/ui"; // constants -import { TOOLBAR_ITEMS } from "@/constants/editor"; +import { TOOLBAR_ITEMS, ToolbarMenuItem } from "@/constants/editor"; // helpers import { cn } from "@/helpers/common.helper"; type Props = { - executeCommand: (commandKey: TEditorCommands) => void; + executeCommand: (item: ToolbarMenuItem) => void; handleSubmit: (event: React.MouseEvent) => void; isCommentEmpty: boolean; isSubmitting: boolean; showSubmitButton: boolean; - editorRef: React.MutableRefObject | null; + editorRef: EditorRefApi | null; }; const toolbarItems = TOOLBAR_ITEMS.lite; @@ -28,24 +28,25 @@ export const IssueCommentToolbar: React.FC = (props) => { // Function to update active states const updateActiveStates = useCallback(() => { - if (editorRef?.current) { - const newActiveStates: Record = {}; - Object.values(toolbarItems) - .flat() - .forEach((item) => { - // Assert that editorRef.current is not null - newActiveStates[item.key] = (editorRef.current as EditorRefApi).isMenuItemActive({ - itemKey: item.key as TNonColorEditorCommands, - }); + if (!editorRef) return; + const newActiveStates: Record = {}; + Object.values(toolbarItems) + .flat() + .forEach((item) => { + // TODO: update this while toolbar homogenization + // @ts-expect-error type mismatch here + newActiveStates[item.renderKey] = editorRef.isMenuItemActive({ + itemKey: item.itemKey, + ...item.extraProps, }); - setActiveStates(newActiveStates); - } + }); + setActiveStates(newActiveStates); }, [editorRef]); // useEffect to call updateActiveStates when isActive prop changes useEffect(() => { - if (!editorRef?.current) return; - const unsubscribe = editorRef.current.onStateChange(updateActiveStates); + if (!editorRef) return; + const unsubscribe = editorRef.onStateChange(updateActiveStates); updateActiveStates(); return () => unsubscribe(); }, [editorRef, updateActiveStates]); @@ -61,35 +62,39 @@ export const IssueCommentToolbar: React.FC = (props) => { "pl-0": index === 0, })} > - {toolbarItems[key].map((item) => ( - - {item.name} - {item.shortcut && {item.shortcut.join(" + ")}} -

- } - > - -
- ))} + + + ); + })} ))} diff --git a/space/core/components/issues/peek-overview/comment/add-comment.tsx b/space/core/components/issues/peek-overview/comment/add-comment.tsx index f6c8a452b52..3623f398641 100644 --- a/space/core/components/issues/peek-overview/comment/add-comment.tsx +++ b/space/core/components/issues/peek-overview/comment/add-comment.tsx @@ -91,7 +91,7 @@ export const AddComment: React.FC = observer((props) => { } onChange={(comment_json, comment_html) => onChange(comment_html)} isSubmitting={isSubmitting} - placeholder="Add Comment..." + placeholder="Add comment..." uploadFile={async (file) => { const { asset_id } = await uploadCommentAsset(file, anchor); setUploadAssetIds((prev) => [...prev, asset_id]); diff --git a/space/core/constants/editor.ts b/space/core/constants/editor.ts index 2c6ac2bbb7a..6089c56046c 100644 --- a/space/core/constants/editor.ts +++ b/space/core/constants/editor.ts @@ -1,5 +1,9 @@ import { + AlignCenter, + AlignLeft, + AlignRight, Bold, + CaseSensitive, Code2, Heading1, Heading2, @@ -19,30 +23,99 @@ import { Underline, } from "lucide-react"; // editor -import { TEditorCommands } from "@plane/editor"; +import { TCommandExtraProps, TEditorCommands } from "@plane/editor"; type TEditorTypes = "lite" | "document"; -export type ToolbarMenuItem = { - key: TEditorCommands; +// Utility type to enforce the necessary extra props or make extraProps optional +type ExtraPropsForCommand = T extends keyof TCommandExtraProps + ? TCommandExtraProps[T] + : object; // Default to empty object for commands without extra props + +export type ToolbarMenuItem = { + itemKey: T; + renderKey: string; name: string; icon: LucideIcon; shortcut?: string[]; editors: TEditorTypes[]; + extraProps?: ExtraPropsForCommand; }; -export const BASIC_MARK_ITEMS: ToolbarMenuItem[] = [ - { key: "h1", name: "Heading 1", icon: Heading1, editors: ["document"] }, - { key: "h2", name: "Heading 2", icon: Heading2, editors: ["document"] }, - { key: "h3", name: "Heading 3", icon: Heading3, editors: ["document"] }, - { key: "h4", name: "Heading 4", icon: Heading4, editors: ["document"] }, - { key: "h5", name: "Heading 5", icon: Heading5, editors: ["document"] }, - { key: "h6", name: "Heading 6", icon: Heading6, editors: ["document"] }, - { key: "bold", name: "Bold", icon: Bold, shortcut: ["Cmd", "B"], editors: ["lite", "document"] }, - { key: "italic", name: "Italic", icon: Italic, shortcut: ["Cmd", "I"], editors: ["lite", "document"] }, - { key: "underline", name: "Underline", icon: Underline, shortcut: ["Cmd", "U"], editors: ["lite", "document"] }, +export const TYPOGRAPHY_ITEMS: ToolbarMenuItem<"text" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6">[] = [ + { itemKey: "text", renderKey: "text", name: "Text", icon: CaseSensitive, editors: ["document"] }, + { itemKey: "h1", renderKey: "h1", name: "Heading 1", icon: Heading1, editors: ["document"] }, + { itemKey: "h2", renderKey: "h2", name: "Heading 2", icon: Heading2, editors: ["document"] }, + { itemKey: "h3", renderKey: "h3", name: "Heading 3", icon: Heading3, editors: ["document"] }, + { itemKey: "h4", renderKey: "h4", name: "Heading 4", icon: Heading4, editors: ["document"] }, + { itemKey: "h5", renderKey: "h5", name: "Heading 5", icon: Heading5, editors: ["document"] }, + { itemKey: "h6", renderKey: "h6", name: "Heading 6", icon: Heading6, editors: ["document"] }, +]; + +export const TEXT_ALIGNMENT_ITEMS: ToolbarMenuItem<"text-align">[] = [ + { + itemKey: "text-align", + renderKey: "text-align-left", + name: "Left align", + icon: AlignLeft, + shortcut: ["Cmd", "Shift", "L"], + editors: ["lite", "document"], + extraProps: { + alignment: "left", + }, + }, + { + itemKey: "text-align", + renderKey: "text-align-center", + name: "Center align", + icon: AlignCenter, + shortcut: ["Cmd", "Shift", "E"], + editors: ["lite", "document"], + extraProps: { + alignment: "center", + }, + }, + { + itemKey: "text-align", + renderKey: "text-align-right", + name: "Right align", + icon: AlignRight, + shortcut: ["Cmd", "Shift", "R"], + editors: ["lite", "document"], + extraProps: { + alignment: "right", + }, + }, +]; + +const BASIC_MARK_ITEMS: ToolbarMenuItem<"bold" | "italic" | "underline" | "strikethrough">[] = [ + { + itemKey: "bold", + renderKey: "bold", + name: "Bold", + icon: Bold, + shortcut: ["Cmd", "B"], + editors: ["lite", "document"], + }, + { + itemKey: "italic", + renderKey: "italic", + name: "Italic", + icon: Italic, + shortcut: ["Cmd", "I"], + editors: ["lite", "document"], + }, + { + itemKey: "underline", + renderKey: "underline", + name: "Underline", + icon: Underline, + shortcut: ["Cmd", "U"], + editors: ["lite", "document"], + }, { - key: "strikethrough", + itemKey: "strikethrough", + renderKey: "strikethrough", name: "Strikethrough", icon: Strikethrough, shortcut: ["Cmd", "Shift", "S"], @@ -50,23 +123,26 @@ export const BASIC_MARK_ITEMS: ToolbarMenuItem[] = [ }, ]; -export const LIST_ITEMS: ToolbarMenuItem[] = [ +const LIST_ITEMS: ToolbarMenuItem<"bulleted-list" | "numbered-list" | "to-do-list">[] = [ { - key: "bulleted-list", + itemKey: "bulleted-list", + renderKey: "bulleted-list", name: "Bulleted list", icon: List, shortcut: ["Cmd", "Shift", "7"], editors: ["lite", "document"], }, { - key: "numbered-list", + itemKey: "numbered-list", + renderKey: "numbered-list", name: "Numbered list", icon: ListOrdered, shortcut: ["Cmd", "Shift", "8"], editors: ["lite", "document"], }, { - key: "to-do-list", + itemKey: "to-do-list", + renderKey: "to-do-list", name: "To-do list", icon: ListTodo, shortcut: ["Cmd", "Shift", "9"], @@ -74,14 +150,14 @@ export const LIST_ITEMS: ToolbarMenuItem[] = [ }, ]; -export const USER_ACTION_ITEMS: ToolbarMenuItem[] = [ - { key: "quote", name: "Quote", icon: TextQuote, editors: ["lite", "document"] }, - { key: "code", name: "Code", icon: Code2, editors: ["lite", "document"] }, +export const USER_ACTION_ITEMS: ToolbarMenuItem<"quote" | "code">[] = [ + { itemKey: "quote", renderKey: "quote", name: "Quote", icon: TextQuote, editors: ["lite", "document"] }, + { itemKey: "code", renderKey: "code", name: "Code", icon: Code2, editors: ["lite", "document"] }, ]; -export const COMPLEX_ITEMS: ToolbarMenuItem[] = [ - { key: "table", name: "Table", icon: Table, editors: ["document"] }, - { key: "image", name: "Image", icon: Image, editors: ["lite", "document"] }, +export const COMPLEX_ITEMS: ToolbarMenuItem<"table" | "image">[] = [ + { itemKey: "table", renderKey: "table", name: "Table", icon: Table, editors: ["document"] }, + { itemKey: "image", renderKey: "image", name: "Image", icon: Image, editors: ["lite", "document"] }, ]; export const TOOLBAR_ITEMS: { @@ -91,12 +167,14 @@ export const TOOLBAR_ITEMS: { } = { lite: { basic: BASIC_MARK_ITEMS.filter((item) => item.editors.includes("lite")), + alignment: TEXT_ALIGNMENT_ITEMS.filter((item) => item.editors.includes("lite")), list: LIST_ITEMS.filter((item) => item.editors.includes("lite")), userAction: USER_ACTION_ITEMS.filter((item) => item.editors.includes("lite")), complex: COMPLEX_ITEMS.filter((item) => item.editors.includes("lite")), }, document: { basic: BASIC_MARK_ITEMS.filter((item) => item.editors.includes("document")), + alignment: TEXT_ALIGNMENT_ITEMS.filter((item) => item.editors.includes("document")), list: LIST_ITEMS.filter((item) => item.editors.includes("document")), userAction: USER_ACTION_ITEMS.filter((item) => item.editors.includes("document")), complex: COMPLEX_ITEMS.filter((item) => item.editors.includes("document")), 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 3e64e83a334..0822f1a97d3 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, TNonColorEditorCommands } from "@plane/editor"; +import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor"; // types import { IUserLite } from "@plane/types"; // components @@ -61,12 +61,12 @@ export const LiteTextEditor = React.forwardRef(ref: React.ForwardedRef): ref is React.MutableRefObject { return !!ref && typeof ref === "object" && "current" in ref; } + // derived values + const isEmpty = isCommentEmpty(props.initialValue); + const editorRef = isMutableRefObject(ref) ? ref.current : null; return (
@@ -89,19 +89,20 @@ export const LiteTextEditor = React.forwardRef { - if (isMutableRefObject(ref)) { - ref.current?.executeMenuItemCommand({ - itemKey: key as TNonColorEditorCommands, - }); - } + executeCommand={(item) => { + // TODO: update this while toolbar homogenization + // @ts-expect-error type mismatch here + editorRef?.executeMenuItemCommand({ + itemKey: item.itemKey, + ...item.extraProps, + }); }} handleAccessChange={handleAccessChange} handleSubmit={(e) => rest.onEnterKeyPress?.(e)} isCommentEmpty={isEmpty} isSubmitting={isSubmitting} showAccessSpecifier={showAccessSpecifier} - editorRef={isMutableRefObject(ref) ? ref : null} + editorRef={editorRef} showSubmitButton={showSubmitButton} />
diff --git a/web/core/components/editor/lite-text-editor/toolbar.tsx b/web/core/components/editor/lite-text-editor/toolbar.tsx index ecf8c3283c8..1951a3170e4 100644 --- a/web/core/components/editor/lite-text-editor/toolbar.tsx +++ b/web/core/components/editor/lite-text-editor/toolbar.tsx @@ -3,25 +3,25 @@ import React, { useEffect, useState, useCallback } from "react"; import { Globe2, Lock, LucideIcon } from "lucide-react"; // editor -import { EditorRefApi, TEditorCommands, TNonColorEditorCommands } from "@plane/editor"; +import { EditorRefApi } from "@plane/editor"; // ui import { Button, Tooltip } from "@plane/ui"; // constants -import { TOOLBAR_ITEMS } from "@/constants/editor"; +import { TOOLBAR_ITEMS, ToolbarMenuItem } from "@/constants/editor"; import { EIssueCommentAccessSpecifier } from "@/constants/issue"; // helpers import { cn } from "@/helpers/common.helper"; type Props = { accessSpecifier?: EIssueCommentAccessSpecifier; - executeCommand: (commandKey: TEditorCommands) => void; + executeCommand: (item: ToolbarMenuItem) => void; handleAccessChange?: (accessKey: EIssueCommentAccessSpecifier) => void; handleSubmit: (event: React.MouseEvent) => void; isCommentEmpty: boolean; isSubmitting: boolean; showAccessSpecifier: boolean; showSubmitButton: boolean; - editorRef: React.MutableRefObject | null; + editorRef: EditorRefApi | null; }; type TCommentAccessType = { @@ -63,24 +63,25 @@ export const IssueCommentToolbar: React.FC = (props) => { // Function to update active states const updateActiveStates = useCallback(() => { - if (editorRef?.current) { - const newActiveStates: Record = {}; - Object.values(toolbarItems) - .flat() - .forEach((item) => { - // Assert that editorRef.current is not null - newActiveStates[item.key] = (editorRef.current as EditorRefApi).isMenuItemActive({ - itemKey: item.key as TNonColorEditorCommands, - }); + if (!editorRef) return; + const newActiveStates: Record = {}; + Object.values(toolbarItems) + .flat() + .forEach((item) => { + // TODO: update this while toolbar homogenization + // @ts-expect-error type mismatch here + newActiveStates[item.renderKey] = editorRef.isMenuItemActive({ + itemKey: item.itemKey, + ...item.extraProps, }); - setActiveStates(newActiveStates); - } + }); + setActiveStates(newActiveStates); }, [editorRef]); // useEffect to call updateActiveStates when isActive prop changes useEffect(() => { - if (!editorRef?.current) return; - const unsubscribe = editorRef.current.onStateChange(updateActiveStates); + if (!editorRef) return; + const unsubscribe = editorRef.onStateChange(updateActiveStates); updateActiveStates(); return () => unsubscribe(); }, [editorRef, updateActiveStates]); @@ -122,35 +123,39 @@ export const IssueCommentToolbar: React.FC = (props) => { "pl-0": index === 0, })} > - {toolbarItems[key].map((item) => ( - - {item.name} - {item.shortcut && {item.shortcut.join(" + ")}} -

- } - > - -
- ))} + + + ); + })} ))} diff --git a/web/core/components/pages/editor/header/color-dropdown.tsx b/web/core/components/pages/editor/header/color-dropdown.tsx index 68de2dc36b0..2809336c17d 100644 --- a/web/core/components/pages/editor/header/color-dropdown.tsx +++ b/web/core/components/pages/editor/header/color-dropdown.tsx @@ -4,13 +4,19 @@ import { memo } from "react"; import { ALargeSmall, Ban } from "lucide-react"; import { Popover } from "@headlessui/react"; // plane editor -import { COLORS_LIST, TColorEditorCommands } from "@plane/editor"; +import { COLORS_LIST, TEditorCommands } 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; + handleColorSelect: ( + key: Extract, + color: string | undefined + ) => void; + isColorActive: ( + key: Extract, + color: string | undefined + ) => boolean; }; export const ColorDropdown: React.FC = memo((props) => { diff --git a/web/core/components/pages/editor/header/toolbar.tsx b/web/core/components/pages/editor/header/toolbar.tsx index 447616b532f..6e4ffdd5f85 100644 --- a/web/core/components/pages/editor/header/toolbar.tsx +++ b/web/core/components/pages/editor/header/toolbar.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useState, useCallback } from "react"; import { Check, ChevronDown } from "lucide-react"; // editor -import { EditorRefApi, TNonColorEditorCommands } from "@plane/editor"; +import { EditorRefApi } from "@plane/editor"; // ui import { CustomMenu, Tooltip } from "@plane/ui"; // components @@ -36,11 +36,13 @@ const ToolbarButton: React.FC = React.memo((props) => { } >