feat: add text color support to the rich text editor (#2903)

* feat: add text color support to the rich text editor

* improve design of text color picker

* Update translations for color picker features in multiple languages

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
Platinum1154
2026-04-26 06:48:03 +08:00
committed by GitHub
parent d6919e340b
commit 907e32a731
65 changed files with 2138 additions and 118 deletions
@@ -176,3 +176,26 @@
text-underline-offset: 0.15rem;
}
}
.color_picker {
overflow: hidden;
border-radius: var(--radius-lg);
background: var(--color-muted);
padding: 0.5rem;
> :global(*) {
width: 100% !important;
}
:global(.w-color-saturation) {
height: 8.5rem !important;
border-radius: calc(var(--radius-lg) - 0.25rem) !important;
}
:global(.w-color-hue),
:global(.w-color-alpha) {
height: 0.75rem !important;
margin-top: 0.5rem !important;
border-radius: 9999px !important;
}
}
+133
View File
@@ -38,9 +38,11 @@ import {
TextUnderlineIcon,
TrashSimpleIcon,
} from "@phosphor-icons/react";
import Color from "@tiptap/extension-color";
import Highlight from "@tiptap/extension-highlight";
import { TableKit } from "@tiptap/extension-table";
import TextAlign from "@tiptap/extension-text-align";
import { TextStyle } from "@tiptap/extension-text-style";
import {
type Editor,
EditorContent,
@@ -50,6 +52,8 @@ import {
useEditorState,
} from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { type ColorResult, hsvaToRgbaString, rgbaStringToHsva } from "@uiw/color-convert";
import ReactColorColorful from "@uiw/react-color-colorful";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { match } from "ts-pattern";
@@ -65,6 +69,8 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Popover, PopoverContent, PopoverHeader, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { usePrompt } from "@/hooks/use-prompt";
import { isRTL } from "@/utils/locale";
import { sanitizeHtml } from "@/utils/sanitize";
@@ -73,6 +79,27 @@ import { cn } from "@/utils/style";
import { Toggle } from "../ui/toggle";
import styles from "./rich-input.module.css";
const defaultTextColor = "rgba(0, 0, 0, 1)";
const richTextColorOptions = [
"rgba(0, 0, 0, 1)",
"rgba(231, 0, 11, 1)",
"rgba(245, 73, 0, 1)",
"rgba(225, 113, 0, 1)",
"rgba(208, 135, 0, 1)",
"rgba(94, 165, 0, 1)",
"rgba(0, 166, 62, 1)",
"rgba(0, 153, 102, 1)",
"rgba(0, 146, 184, 1)",
"rgba(0, 132, 209, 1)",
"rgba(21, 93, 252, 1)",
"rgba(79, 57, 246, 1)",
"rgba(127, 34, 254, 1)",
"rgba(200, 0, 222, 1)",
"rgba(230, 0, 118, 1)",
"rgba(69, 85, 108, 1)",
] as const;
const extensions = [
StarterKit.configure({
heading: {
@@ -88,6 +115,8 @@ const extensions = [
protocols: ["http", "https"],
},
}),
TextStyle,
Color,
Highlight.configure({
HTMLAttributes: {
class: "rounded-md px-0.5 py-px",
@@ -231,6 +260,12 @@ function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
canHighlight: ctx.editor.can().chain().toggleHighlight().run() ?? false,
toggleHighlight: () => ctx.editor.chain().focus().toggleHighlight().run(),
// Text Color
textColor: (ctx.editor.getAttributes("textStyle").color as string | undefined) ?? null,
canTextColor: ctx.editor.can().chain().setColor(defaultTextColor).run() ?? false,
setTextColor: (color: string) => ctx.editor.chain().focus().setColor(color).run(),
unsetTextColor: () => ctx.editor.chain().focus().unsetColor().run(),
// Heading 1
isHeading1: ctx.editor.isActive("heading", { level: 1 }) ?? false,
canHeading1: ctx.editor.can().chain().toggleHeading({ level: 1 }).run() ?? false,
@@ -426,6 +461,104 @@ function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
<HighlighterCircleIcon className="size-3.5" />
</Toggle>
<Popover>
<PopoverTrigger
render={
<Button
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
variant="ghost"
className={cn("rounded-none px-2", state.textColor && "bg-muted text-foreground")}
title={t`Text Color`}
disabled={!state.canTextColor}
>
<span className="flex flex-col items-center leading-none">
<span className="text-xs font-semibold">A</span>
<span
className="mt-0.5 h-0.5 w-3 rounded-full"
style={{ backgroundColor: state.textColor ?? "currentColor" }}
/>
</span>
</Button>
}
/>
<PopoverContent sideOffset={8} className="w-80 gap-3 rounded-xl p-3 shadow-xl">
<PopoverHeader className="flex-row items-start justify-between gap-2">
<div className="flex items-center gap-2.5">
<span
className="grid size-9 place-items-center rounded-lg border border-border bg-muted/60 text-sm font-semibold shadow-xs"
style={{ color: state.textColor ?? "currentColor" }}
>
A
</span>
<div className="flex flex-col gap-0.5">
<PopoverTitle>
<Trans>Text Color</Trans>
</PopoverTitle>
<span className="text-xs text-muted-foreground">
<Trans comment="Preset or custom shade refer to the color picker">
Choose a preset or custom shade.
</Trans>
</span>
</div>
</div>
<Button
size="xs"
variant="ghost"
className="shrink-0"
onClick={state.unsetTextColor}
disabled={!state.textColor}
>
<Trans comment="Clear the text color">Clear</Trans>
</Button>
</PopoverHeader>
<Separator />
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-muted-foreground">
<Trans>Presets</Trans>
</span>
<div className="grid grid-cols-8 gap-2 rounded-lg bg-muted/40 p-2">
{richTextColorOptions.map((color) => (
<button
key={color}
type="button"
className={cn(
"size-7 rounded-full border border-border shadow-xs ring-offset-background transition-transform hover:scale-105 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-hidden",
state.textColor === color && "ring-2 ring-ring ring-offset-2",
)}
style={{ backgroundColor: color }}
title={color}
aria-label={t`Use color ${color}`}
aria-pressed={state.textColor === color}
onClick={() => state.setTextColor(color)}
/>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-muted-foreground">
<Trans>Custom</Trans>
</span>
<div className={styles.color_picker}>
<ReactColorColorful
color={rgbaStringToHsva(state.textColor ?? defaultTextColor)}
onChange={(color: ColorResult) => {
state.setTextColor(hsvaToRgbaString(color.hsva));
}}
/>
</div>
</div>
</PopoverContent>
</Popover>
<div className="mx-1 h-5 w-px bg-border" />
<DropdownMenu>