mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-24 08:54:05 +10:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user