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