diff --git a/components/email/__tests__/font-size.test.ts b/components/email/__tests__/font-size.test.ts
index 46804c9ee..613942edb 100644
--- a/components/email/__tests__/font-size.test.ts
+++ b/components/email/__tests__/font-size.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
-import { TextStyle } from '@tiptap/extension-text-style';
+import { TextStyle, BackgroundColor } from '@tiptap/extension-text-style';
import Color from '@tiptap/extension-color';
import { FontSize, FONT_SIZES } from '../font-size';
@@ -11,7 +11,7 @@ import { FontSize, FONT_SIZES } from '../font-size';
function makeEditor(content = '
Hello world
') {
return new Editor({
element: document.createElement('div'),
- extensions: [StarterKit, TextStyle, Color, FontSize],
+ extensions: [StarterKit, TextStyle, Color, BackgroundColor, FontSize],
content,
});
}
@@ -72,3 +72,61 @@ describe('font-size extension', () => {
expect([...FONT_SIZES]).toEqual(['14px', '16px', '18px', '20px', '24px', '32px']);
});
});
+
+describe('background-color extension', () => {
+ it('applies background-color as an inline style on the selection', () => {
+ const editor = makeEditor();
+ editor.commands.selectAll();
+ editor.commands.setBackgroundColor('#fef3c7');
+ const html = editor.getHTML();
+ // jsdom normalizes #fef3c7 to rgb() form.
+ expect(html).toContain('background-color: rgb(254, 243, 199)');
+ editor.destroy();
+ });
+
+ it('reports the active tint via the textStyle attributes', () => {
+ const editor = makeEditor();
+ editor.commands.selectAll();
+ editor.commands.setBackgroundColor('#dcfce7');
+ // getAttributes returns the stored attribute verbatim (no jsdom
+ // round-trip), unlike getHTML which normalizes to rgb() form.
+ expect(editor.getAttributes('textStyle').backgroundColor).toBe('#dcfce7');
+ editor.destroy();
+ });
+
+ it('unsetBackgroundColor removes the attribute and the empty textStyle mark', () => {
+ const editor = makeEditor();
+ editor.commands.selectAll();
+ editor.commands.setBackgroundColor('#dbeafe');
+ editor.commands.unsetBackgroundColor();
+ expect(editor.getHTML()).not.toContain('background-color');
+ editor.destroy();
+ });
+
+ it('parses background-color from pasted inline styles', () => {
+ const editor = makeEditor('
Marked rest
');
+ editor.commands.setTextSelection({ from: 1, to: 5 });
+ // parseHTML reads the raw style attribute, so the hex form survives.
+ expect(editor.getAttributes('textStyle').backgroundColor).toBe('#fde68a');
+ editor.destroy();
+ });
+
+ it('keeps color, background-color and font-size on the same span', () => {
+ const editor = makeEditor();
+ editor.commands.selectAll();
+ editor.commands.setFontSize('18px');
+ editor.commands.setColor('#c5221f');
+ editor.commands.setBackgroundColor('#fef3c7');
+ const html = editor.getHTML();
+ expect(html).toContain('font-size: 18px');
+ expect(html).toContain('color: rgb(197, 34, 31)');
+ expect(html).toContain('background-color: rgb(254, 243, 199)');
+ // Removing the tint must keep the other two attributes.
+ editor.commands.unsetBackgroundColor();
+ const after = editor.getHTML();
+ expect(after).not.toContain('background-color');
+ expect(after).toContain('font-size: 18px');
+ expect(after).toContain('color: rgb(197, 34, 31)');
+ editor.destroy();
+ });
+});
diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx
index ba0922666..661cbb066 100644
--- a/components/email/rich-text-editor.tsx
+++ b/components/email/rich-text-editor.tsx
@@ -9,7 +9,7 @@ import Underline from "@tiptap/extension-underline";
import Link from "@tiptap/extension-link";
import TextAlign from "@tiptap/extension-text-align";
import { TextDirection } from "@/components/email/text-direction";
-import { TextStyle } from "@tiptap/extension-text-style";
+import { TextStyle, BackgroundColor } from "@tiptap/extension-text-style";
import Color from "@tiptap/extension-color";
import { FontSize, FONT_SIZES } from "@/components/email/font-size";
import { ResizableImage } from "@/components/email/resizable-image";
@@ -45,6 +45,8 @@ import {
Heading2,
Table as TableIcon,
Baseline,
+ Highlighter,
+ ALargeSmall,
Trash2,
Rows3,
Columns3,
@@ -128,6 +130,15 @@ const TEXT_COLORS = [
"#7627bb", "#c2185b", "#795548", "#fa5252", "#fd7e14", "#40c057", "#4dabf7", "#e64980",
];
+// Highlighter presets (2 x 8) - soft, text-legible tints rather than the
+// fully-saturated TEXT_COLORS, so highlighted text stays readable.
+// Inline `style="background-color: …"` survives email round-trips the same
+// way `color` does; the BackgroundColor extension preserves pasted ones.
+const BACKGROUND_COLORS = [
+ "#fef3c7", "#fee2e2", "#dcfce7", "#dbeafe", "#fae8ff", "#ffe4e6", "#ecfccb", "#f1f5f9",
+ "#fde68a", "#fecaca", "#bbf7d0", "#bfdbfe", "#f5d0fe", "#ffd6e7", "#e7f9c4", "#e2e8f0",
+];
+
function TableSizePicker({ onPick }: { onPick: (rows: number, cols: number) => void }) {
const t = useTranslations("email_composer.toolbar");
const [hover, setHover] = useState<{ r: number; c: number } | null>(null);
@@ -199,6 +210,7 @@ export function RichTextEditor({
}),
TextStyle,
Color,
+ BackgroundColor,
FontSize,
ResizableImage,
Placeholder.configure({
@@ -328,6 +340,8 @@ export function RichTextEditor({
const tableWrapperRef = useRef(null);
const [colorMenuOpen, setColorMenuOpen] = useState(false);
const colorWrapperRef = useRef(null);
+ const [bgColorMenuOpen, setBgColorMenuOpen] = useState(false);
+ const bgColorWrapperRef = useRef(null);
const [fontSizeMenuOpen, setFontSizeMenuOpen] = useState(false);
const fontSizeWrapperRef = useRef(null);
@@ -353,6 +367,17 @@ export function RichTextEditor({
return () => document.removeEventListener("mousedown", handler);
}, [colorMenuOpen]);
+ useEffect(() => {
+ if (!bgColorMenuOpen) return;
+ const handler = (e: MouseEvent) => {
+ if (bgColorWrapperRef.current && !bgColorWrapperRef.current.contains(e.target as Node)) {
+ setBgColorMenuOpen(false);
+ }
+ };
+ document.addEventListener("mousedown", handler);
+ return () => document.removeEventListener("mousedown", handler);
+ }, [bgColorMenuOpen]);
+
useEffect(() => {
if (!tableMenuOpen) return;
const handler = (e: MouseEvent) => {
@@ -371,6 +396,7 @@ export function RichTextEditor({
}
const currentFontSize: string | null = editor.getAttributes("textStyle").fontSize ?? null;
+ const currentBgColor: string | null = editor.getAttributes("textStyle").backgroundColor ?? null;
return (
@@ -382,15 +408,21 @@ export function RichTextEditor({
extra opaque overlay (an always-on stronger overlay would read darker
than main even at rest). */}
- {/* Font size */}
+ {/* Font size - big-A/small-A glyph reads as "size" at a glance,
+ with the active size as a small live badge. */}