feat: adds an underline option in the rich text editor (#6274)

Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
Piyush Gupta
2025-07-23 16:24:05 +05:30
committed by GitHub
parent d08ec4c9ab
commit ee20af54c3
5 changed files with 254 additions and 90 deletions
@@ -28,6 +28,7 @@ import { ToolbarPlugin } from "./toolbar-plugin";
- bold
- italic
- link
- underline
*/
export type TextEditorProps = {
getText: () => string;
@@ -147,6 +147,7 @@ vi.mock("lucide-react", () => ({
Bold: () => <span data-testid="bold-icon">Bold</span>,
Italic: () => <span data-testid="italic-icon">Italic</span>,
Link: () => <span data-testid="link-icon">Link</span>,
Underline: () => <span data-testid="underline-icon">Underline</span>,
ChevronDownIcon: () => <span data-testid="chevron-icon">ChevronDown</span>,
}));
@@ -186,6 +187,7 @@ describe("ToolbarPlugin", () => {
expect(screen.getByTestId("dropdown-menu")).toBeInTheDocument();
expect(screen.getByTestId("bold-icon")).toBeInTheDocument();
expect(screen.getByTestId("italic-icon")).toBeInTheDocument();
expect(screen.getByTestId("underline-icon")).toBeInTheDocument();
expect(screen.getByTestId("link-icon")).toBeInTheDocument();
});
@@ -218,20 +220,57 @@ describe("ToolbarPlugin", () => {
});
test("excludes toolbar items when specified", () => {
render(
const { rerender } = render(
<ToolbarPlugin
getText={() => "Sample text"}
setText={vi.fn()}
editable={true}
container={document.createElement("div")}
excludedToolbarItems={["bold", "italic"]}
excludedToolbarItems={["bold", "italic", "underline"]}
/>
);
// Should not render bold and italic buttons but should render link
expect(screen.queryByTestId("bold-icon")).not.toBeInTheDocument();
expect(screen.queryByTestId("italic-icon")).not.toBeInTheDocument();
expect(screen.queryByTestId("underline-icon")).not.toBeInTheDocument();
expect(screen.getByTestId("link-icon")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-menu")).toBeInTheDocument();
// Rerender with different excluded items
rerender(
<ToolbarPlugin
getText={() => "Sample text"}
setText={vi.fn()}
editable={true}
container={document.createElement("div")}
excludedToolbarItems={["blockType", "link"]}
/>
);
expect(screen.queryByTestId("dropdown-menu")).not.toBeInTheDocument();
expect(screen.queryByTestId("link-icon")).not.toBeInTheDocument();
expect(screen.getByTestId("bold-icon")).toBeInTheDocument();
expect(screen.getByTestId("italic-icon")).toBeInTheDocument();
expect(screen.getByTestId("underline-icon")).toBeInTheDocument();
});
test("excludes all toolbar items when specified", () => {
render(
<ToolbarPlugin
getText={() => "Sample text"}
setText={vi.fn()}
editable={true}
container={document.createElement("div")}
excludedToolbarItems={["blockType", "bold", "italic", "underline", "link"]}
/>
);
expect(screen.queryByTestId("dropdown-menu")).not.toBeInTheDocument();
expect(screen.queryByTestId("bold-icon")).not.toBeInTheDocument();
expect(screen.queryByTestId("italic-icon")).not.toBeInTheDocument();
expect(screen.queryByTestId("underline-icon")).not.toBeInTheDocument();
expect(screen.queryByTestId("link-icon")).not.toBeInTheDocument();
});
test("handles firstRender and updateTemplate props", () => {
@@ -253,4 +292,122 @@ describe("ToolbarPlugin", () => {
// the component renders without errors when these props are provided
expect(screen.getByTestId("dropdown-menu")).toBeInTheDocument();
});
describe("User Interactions", () => {
test("dispatches bold format command on click", async () => {
render(
<ToolbarPlugin
getText={() => "Sample text"}
setText={vi.fn()}
editable={true}
container={document.createElement("div")}
/>
);
const boldIcon = screen.getByTestId("bold-icon");
const boldButton = boldIcon.parentElement;
expect(boldButton).toBeInTheDocument();
expect(boldButton).not.toBeNull();
await userEvent.click(boldButton!);
expect(mockEditor.dispatchCommand).toHaveBeenCalledWith("formatText", "bold");
});
test("dispatches italic format command on click", async () => {
render(
<ToolbarPlugin
getText={() => "Sample text"}
setText={vi.fn()}
editable={true}
container={document.createElement("div")}
/>
);
const italicIcon = screen.getByTestId("italic-icon");
const italicButton = italicIcon.parentElement;
expect(italicButton).toBeInTheDocument();
expect(italicButton).not.toBeNull();
await userEvent.click(italicButton!);
expect(mockEditor.dispatchCommand).toHaveBeenCalledWith("formatText", "italic");
});
test("dispatches underline format command on click", async () => {
render(
<ToolbarPlugin
getText={() => "Sample text"}
setText={vi.fn()}
editable={true}
container={document.createElement("div")}
/>
);
const underlineIcon = screen.getByTestId("underline-icon");
const underlineButton = underlineIcon.parentElement;
expect(underlineButton).toBeInTheDocument();
expect(underlineButton).not.toBeNull();
await userEvent.click(underlineButton!);
expect(mockEditor.dispatchCommand).toHaveBeenCalledWith("formatText", "underline");
});
test("dispatches link command on click", async () => {
render(
<ToolbarPlugin
getText={() => "Sample text"}
setText={vi.fn()}
editable={true}
container={document.createElement("div")}
/>
);
const linkIcon = screen.getByTestId("link-icon");
const linkButton = linkIcon.parentElement;
expect(linkButton).toBeInTheDocument();
expect(linkButton).not.toBeNull();
await userEvent.click(linkButton!);
expect(mockEditor.dispatchCommand).toHaveBeenCalledWith("toggleLink", {
url: "https://",
});
});
test("dispatches numbered list command on click", async () => {
render(
<ToolbarPlugin
getText={() => "Sample text"}
setText={vi.fn()}
editable={true}
container={document.createElement("div")}
/>
);
const dropdownTrigger = screen.getByTestId("dropdown-menu-trigger");
await userEvent.click(dropdownTrigger);
const numberedListButton = screen.getAllByTestId("button")[1]; // ol
await userEvent.click(numberedListButton);
expect(mockEditor.dispatchCommand).toHaveBeenCalledWith("insertOrderedList", undefined);
});
test("dispatches bulleted list command on click", async () => {
render(
<ToolbarPlugin
getText={() => "Sample text"}
setText={vi.fn()}
editable={true}
container={document.createElement("div")}
/>
);
const dropdownTrigger = screen.getByTestId("dropdown-menu-trigger");
await userEvent.click(dropdownTrigger);
const bulletedListButton = screen.getAllByTestId("button")[2]; // ul
await userEvent.click(bulletedListButton);
expect(mockEditor.dispatchCommand).toHaveBeenCalledWith("insertUnorderedList", undefined);
});
});
});
@@ -29,11 +29,12 @@ import {
$getSelection,
$insertNodes,
$isRangeSelection,
COMMAND_PRIORITY_CRITICAL,
FORMAT_TEXT_COMMAND,
PASTE_COMMAND,
SELECTION_CHANGE_COMMAND,
} from "lexical";
import { COMMAND_PRIORITY_CRITICAL, PASTE_COMMAND } from "lexical";
import { Bold, ChevronDownIcon, Italic, Link } from "lucide-react";
import { Bold, ChevronDownIcon, Italic, Link, Underline } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { AddVariablesDropdown } from "./add-variables-dropdown";
@@ -235,6 +236,7 @@ export const ToolbarPlugin = (props: TextEditorProps & { container: HTMLElement
const [isLink, setIsLink] = useState(false);
const [isBold, setIsBold] = useState(false);
const [isItalic, setIsItalic] = useState(false);
const [isUnderline, setIsUnderline] = useState(false);
// save ref to setText to use it in event listeners safely
const setText = useRef<any>(props.setText);
@@ -334,7 +336,7 @@ export const ToolbarPlugin = (props: TextEditorProps & { container: HTMLElement
}
setIsBold(selection.hasFormat("bold"));
setIsItalic(selection.hasFormat("italic"));
setIsUnderline(selection.hasFormat("underline"));
const node = getSelectedNode(selection);
const parent = node.getParent();
if ($isLinkNode(parent) || $isLinkNode(node)) {
@@ -459,95 +461,94 @@ export const ToolbarPlugin = (props: TextEditorProps & { container: HTMLElement
if (!props.editable) return <></>;
const items = [
{
key: "bold",
icon: Bold,
onClick: () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold"),
active: isBold,
},
{
key: "italic",
icon: Italic,
onClick: () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic"),
active: isItalic,
},
{
key: "underline",
icon: Underline,
onClick: () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline"),
active: isUnderline,
},
{
key: "link",
icon: Link,
onClick: insertLink,
active: isLink,
},
];
return (
<div className="toolbar flex" ref={toolbarRef}>
<>
{!props.excludedToolbarItems?.includes("blockType") && supportedBlockTypes.has(blockType) && (
<>
<DropdownMenu>
<DropdownMenuTrigger className="text-subtle">
<>
<span className={"icon" + blockType} />
<span className="text text-default hidden sm:flex">
{blockTypeToBlockName[blockType as keyof BlockType]}
</span>
<ChevronDownIcon className="text-default ml-2 h-4 w-4" />
</>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{Object.keys(blockTypeToBlockName).map((key) => {
return (
<DropdownMenuItem key={key}>
<Button
type="button"
onClick={() => format(key)}
className={cn(
"w-full rounded-none focus:ring-0",
blockType === key ? "bg-subtle w-full" : ""
)}>
<>
<span className={"icon block-type " + key} />
<span>{blockTypeToBlockName[key]}</span>
</>
</Button>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</>
)}
<>
{!props.excludedToolbarItems?.includes("bold") && (
<Button
variant="ghost"
type="button"
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold");
}}
className={isBold ? "bg-subtle active-button" : "inactive-button"}>
<Bold />
</Button>
)}
{!props.excludedToolbarItems?.includes("italic") && (
<Button
variant="ghost"
type="button"
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic");
}}
className={isItalic ? "bg-subtle active-button" : "inactive-button"}>
<Italic />
</Button>
)}
{!props.excludedToolbarItems?.includes("link") && (
{!props.excludedToolbarItems?.includes("blockType") && supportedBlockTypes.has(blockType) && (
<DropdownMenu>
<DropdownMenuTrigger className="text-subtle">
<>
<Button
variant="ghost"
type="button"
onClick={insertLink}
className={isLink ? "bg-subtle active-button" : "inactive-button"}>
<Link />
</Button>
{isLink ? (
createPortal(<FloatingLinkEditor editor={editor} />, props.container ?? document.body)
) : (
<></>
)}
<span className={cn("icon", blockType)} />
<span className="text text-default hidden sm:flex">
{blockTypeToBlockName[blockType as keyof BlockType]}
</span>
<ChevronDownIcon className="text-default ml-2 h-4 w-4" />
</>
)}
</>
{props.variables && (
<div className="ml-auto">
<AddVariablesDropdown
addVariable={addVariable}
isTextEditor={true}
variables={props.variables || []}
/>
</div>
)}
</>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{Object.keys(blockTypeToBlockName).map((key) => {
return (
<DropdownMenuItem key={key}>
<Button
type="button"
onClick={() => format(key)}
className={cn(
"w-full rounded-none focus:ring-0",
blockType === key ? "bg-subtle w-full" : ""
)}>
<>
<span className={cn("icon block-type", key)} />
<span>{blockTypeToBlockName[key]}</span>
</>
</Button>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
{items.map(({ key, icon: Icon, onClick, active }) =>
!props.excludedToolbarItems?.includes(key) ? (
<Button
key={key}
variant="ghost"
type="button"
onClick={onClick}
className={active ? "bg-subtle active-button" : "inactive-button"}>
<Icon />
</Button>
) : null
)}
{isLink &&
!props.excludedToolbarItems?.includes("link") &&
createPortal(<FloatingLinkEditor editor={editor} />, props.container ?? document.body)}
{props.variables && (
<div className="ml-auto">
<AddVariablesDropdown
addVariable={addVariable}
isTextEditor={true}
variables={props.variables || []}
/>
</div>
)}
</div>
);
};
@@ -20,5 +20,6 @@ export const exampleTheme = {
text: {
bold: "fb-editor-text-bold",
italic: "fb-editor-text-italic",
underline: "fb-editor-text-underline",
},
};
@@ -6,6 +6,10 @@
font-style: italic !important;
}
.fb-editor-text-underline {
text-decoration: underline !important;
}
.fb-editor-link {
text-decoration: underline !important;
}