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>
+1 -1
View File
@@ -5,7 +5,7 @@ interface CommonControlledStateProps<T> {
defaultValue?: T;
}
export type UseControlledStateProps<T, Rest extends any[] = []> = CommonControlledStateProps<T> & {
type UseControlledStateProps<T, Rest extends any[] = []> = CommonControlledStateProps<T> & {
onChange?: (value: T, ...args: Rest) => void;
};
+1 -1
View File
@@ -1,6 +1,6 @@
import { z } from "zod";
export const AI_PROVIDERS = ["openai", "anthropic", "gemini", "vercel-ai-gateway", "openrouter", "ollama"] as const;
const AI_PROVIDERS = ["openai", "anthropic", "gemini", "vercel-ai-gateway", "openrouter", "ollama"] as const;
export type AIProvider = (typeof AI_PROVIDERS)[number];
+4 -4
View File
@@ -4,7 +4,7 @@ import webFontListJSON from "@/components/typography/webfontlist.json";
type FontCategory = LocalFont["category"];
type FontWeight = LocalFont["weights"][number];
export type FontRecord = LocalFont | WebFont;
type FontRecord = LocalFont | WebFont;
const preferredChineseFontFamilies = [
"Noto Sans SC",
@@ -101,7 +101,7 @@ const genericFontFamilies = new Set([
"ui-serif",
]);
export const webFontList = webFontListJSON as WebFont[];
const webFontList = webFontListJSON as WebFont[];
export const webFontMap = new Map<string, WebFont>(webFontList.map((font) => [font.family, font]));
const webFontFamilies = new Set(webFontList.map((font) => font.family));
@@ -138,7 +138,7 @@ export function getFont(family: string) {
return fontMap.get(family);
}
export function getFontCategory(family: string): FontCategory | null {
function getFontCategory(family: string): FontCategory | null {
return getFont(family)?.category ?? null;
}
@@ -158,7 +158,7 @@ function getCjkFallbacksByCategory(category: FontCategory | null) {
return category === "serif" ? resumeCjkSerifFontFallbacks : resumeCjkSansFontFallbacks;
}
export function getPrimaryCjkWebFont(family: string) {
function getPrimaryCjkWebFont(family: string) {
const category = getFontCategory(family);
return category === "serif" ? "Noto Serif SC" : "Noto Sans SC";
}
@@ -229,6 +229,17 @@ describe("htmlToParagraphs", () => {
expect(result).toHaveLength(1);
});
it("preserves inline text colors from rich text spans", () => {
const result = htmlToParagraphs('<p><span style="color: rgba(21, 93, 252, 1)">Styled</span></p>');
expect(result).toHaveLength(1);
const children = getChildren(result[0] as Paragraph);
const textRun = children.find((child) => child instanceof TextRun);
expect(textRun).toBeDefined();
expect(JSON.stringify(textRun)).toContain('"val":"155DFC"');
});
it("applies linkColor from styleConfig", () => {
const result = htmlToParagraphs('<p><a href="https://example.com">link</a></p>', {
linkColor: "FF0000",
+30 -14
View File
@@ -7,6 +7,8 @@ import {
TextRun,
} from "docx";
import { parseColorString } from "@/utils/color";
import { toSafeDocxLink } from "./link-utils";
export interface HtmlStyleConfig {
@@ -41,7 +43,14 @@ const HEADING_MAP: Record<string, (typeof HeadingLevel)[keyof typeof HeadingLeve
H6: HeadingLevel.HEADING_6,
};
function mergeStyle(parent: InlineStyle, tag: string): InlineStyle {
function toDocxColorValue(value: string) {
const rgba = parseColorString(value);
if (!rgba) return null;
return [rgba.r, rgba.g, rgba.b].map((channel) => channel.toString(16).padStart(2, "0").toUpperCase()).join("");
}
function mergeStyle(parent: InlineStyle, tag: string, element?: HTMLElement): InlineStyle {
const next = { ...parent };
switch (tag) {
@@ -68,6 +77,12 @@ function mergeStyle(parent: InlineStyle, tag: string): InlineStyle {
break;
}
const colorValue = (element as HTMLElement | undefined)?.style.color;
if (colorValue) {
const color = toDocxColorValue(colorValue);
if (color) next.color = color;
}
return next;
}
@@ -85,7 +100,7 @@ function collectInlineChildren(node: Node, style: InlineStyle): InlineChild[] {
if (child.nodeType !== Node.ELEMENT_NODE) continue;
const el = child as Element;
const el = child as HTMLElement;
const tag = el.tagName;
if (tag === "BR") {
@@ -104,7 +119,7 @@ function collectInlineChildren(node: Node, style: InlineStyle): InlineChild[] {
continue;
}
const merged = mergeStyle(style, tag);
const merged = mergeStyle(style, tag, el);
children.push(...collectInlineChildren(el, merged));
}
@@ -112,7 +127,7 @@ function collectInlineChildren(node: Node, style: InlineStyle): InlineChild[] {
}
function processBlockElement(
el: Element,
el: HTMLElement,
style: InlineStyle,
paragraphs: Paragraph[],
listLevel?: number,
@@ -120,9 +135,10 @@ function processBlockElement(
listIndex?: number,
): void {
const tag = el.tagName;
const mergedStyle = mergeStyle(style, tag, el);
if (HEADING_MAP[tag]) {
const inlineChildren = collectInlineChildren(el, style);
const inlineChildren = collectInlineChildren(el, mergedStyle);
if (inlineChildren.length > 0) {
paragraphs.push(new Paragraph({ heading: HEADING_MAP[tag], children: inlineChildren }));
}
@@ -130,7 +146,7 @@ function processBlockElement(
}
if (tag === "P" || tag === "DIV") {
const inlineChildren = collectInlineChildren(el, style);
const inlineChildren = collectInlineChildren(el, mergedStyle);
if (inlineChildren.length > 0) {
paragraphs.push(
new Paragraph({
@@ -162,7 +178,7 @@ function processBlockElement(
if (text) {
paragraphs.push(
new Paragraph({
children: [new TextRun({ text, ...style })],
children: [new TextRun({ text, ...mergedStyle })],
...(isOrdered && numberingRef
? { numbering: { reference: numberingRef, level, instance: listIndex } }
: { bullet: { level } }),
@@ -170,11 +186,11 @@ function processBlockElement(
);
}
} else if (liChild.nodeType === Node.ELEMENT_NODE) {
processBlockElement(liChild as Element, style, paragraphs, level, numberingRef, listIndex);
processBlockElement(liChild as HTMLElement, mergedStyle, paragraphs, level, numberingRef, listIndex);
}
}
} else {
const inlineChildren = collectInlineChildren(li, style);
const inlineChildren = collectInlineChildren(li, mergedStyle);
if (inlineChildren.length > 0) {
paragraphs.push(
new Paragraph({
@@ -192,7 +208,7 @@ function processBlockElement(
if (tag === "BLOCKQUOTE") {
const indent: ISpacingProperties = {};
const inlineChildren = collectInlineChildren(el, { ...style, italics: true });
const inlineChildren = collectInlineChildren(el, { ...mergedStyle, italics: true });
if (inlineChildren.length > 0) {
paragraphs.push(
new Paragraph({
@@ -210,7 +226,7 @@ function processBlockElement(
if (text) {
paragraphs.push(
new Paragraph({
children: [new TextRun({ text, font: "Courier New", ...style })],
children: [new TextRun({ text, font: "Courier New", ...mergedStyle })],
}),
);
}
@@ -223,7 +239,7 @@ function processBlockElement(
}
if (tag === "LI") {
const inlineChildren = collectInlineChildren(el, style);
const inlineChildren = collectInlineChildren(el, mergedStyle);
if (inlineChildren.length > 0) {
paragraphs.push(
new Paragraph({
@@ -236,7 +252,7 @@ function processBlockElement(
}
// Fallback: treat as inline container
const inlineChildren = collectInlineChildren(el, style);
const inlineChildren = collectInlineChildren(el, mergedStyle);
if (inlineChildren.length > 0) {
paragraphs.push(new Paragraph({ children: inlineChildren }));
}
@@ -274,7 +290,7 @@ export function htmlToParagraphs(html: string, styleConfig?: HtmlStyleConfig): P
}
if (child.nodeType === Node.ELEMENT_NODE) {
processBlockElement(child as Element, baseStyle, paragraphs);
processBlockElement(child as HTMLElement, baseStyle, paragraphs);
}
}
+9
View File
@@ -39,6 +39,15 @@ describe("sanitizeHtml", () => {
const html = "<table><tr><td>cell</td></tr></table>";
expect(sanitizeHtml(html)).toContain("<table>");
});
it("should preserve safe inline text colors", () => {
const html = '<p><span style="color: rgba(21, 93, 252, 1)">colored</span></p>';
const result = sanitizeHtml(html);
expect(result).toContain("<span");
expect(result).toContain("color:");
expect(result).toContain("colored");
});
});
describe("sanitizeCss", () => {