feat(composer): add background color and clearer font-size button

- Background color toolbar button next to text color, using the
  BackgroundColor extension from @tiptap/extension-text-style (same
  textStyle-mark pattern as Color) with a 2x8 soft-tint highlighter
  palette and a remove action; emitted as inline
  style="background-color: …" which survives email round-trips.
- Font-size button now shows a big-A/small-A glyph (ALargeSmall) with
  the active size as a small badge, replacing the plain "Aa" text that
  was easy to miss.
- background_color/remove_background_color toolbar labels in all 24
  locales; unit tests for apply/unset/parse/coexistence with color and
  font-size on the same span.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ibayue
2026-09-07 16:58:54 +08:00
co-authored by Claude
parent 8fbe508ba3
commit 3957b35809
27 changed files with 223 additions and 32 deletions
+60 -2
View File
@@ -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 = '<p>Hello world</p>') {
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('<p><span style="background-color: #fde68a">Marked</span> rest</p>');
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();
});
});
+88 -5
View File
@@ -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<HTMLDivElement>(null);
const [colorMenuOpen, setColorMenuOpen] = useState(false);
const colorWrapperRef = useRef<HTMLDivElement>(null);
const [bgColorMenuOpen, setBgColorMenuOpen] = useState(false);
const bgColorWrapperRef = useRef<HTMLDivElement>(null);
const [fontSizeMenuOpen, setFontSizeMenuOpen] = useState(false);
const fontSizeWrapperRef = useRef<HTMLDivElement>(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 (
<div className={cn("flex flex-col", hasError && "ring-2 ring-red-500 dark:ring-red-400 rounded", className)}>
@@ -382,15 +408,21 @@ export function RichTextEditor({
extra opaque overlay (an always-on stronger overlay would read darker
than main even at rest). */}
<div className="sticky top-0 z-20 flex flex-wrap items-center gap-0.5 px-3 py-1.5 border-b border-border/50 bg-muted/30 backdrop-blur-sm">
{/* Font size */}
{/* Font size - big-A/small-A glyph reads as "size" at a glance,
with the active size as a small live badge. */}
<div ref={fontSizeWrapperRef} className="relative">
<ToolbarButton
active={fontSizeMenuOpen}
active={fontSizeMenuOpen || !!currentFontSize}
onClick={() => setFontSizeMenuOpen((v) => !v)}
title={tToolbar("font_size")}
>
<span className="text-xs font-medium min-w-6 text-center leading-4">
{currentFontSize ? currentFontSize.replace("px", "") : "Aa"}
<span className="relative inline-flex items-center justify-center">
<ALargeSmall className="w-4 h-4" />
{currentFontSize && (
<span className="absolute -bottom-1 -end-1 text-[8px] font-semibold leading-none text-primary">
{currentFontSize.replace("px", "")}
</span>
)}
</span>
</ToolbarButton>
{fontSizeMenuOpen && (
@@ -500,6 +532,57 @@ export function RichTextEditor({
</div>
)}
</div>
{/* Background color - same shape as the text-colour control: the
highlighter glyph previews the active tint on its little bar. */}
<div ref={bgColorWrapperRef} className="relative">
<ToolbarButton
active={!!currentBgColor}
onClick={() => setBgColorMenuOpen((v) => !v)}
title={tToolbar("background_color")}
>
<span className="relative inline-flex items-center justify-center">
<Highlighter className="w-4 h-4" />
<span
aria-hidden
className="absolute -bottom-[3px] left-1/2 h-[3px] w-3 -translate-x-1/2 rounded-[1px] border border-border/40"
style={{ backgroundColor: currentBgColor || undefined }}
/>
</span>
</ToolbarButton>
{bgColorMenuOpen && (
<div className="absolute z-50 top-full start-0 mt-1 bg-popover border border-border rounded-md shadow-md p-2">
<div className="grid gap-0.5" style={{ gridTemplateColumns: "repeat(8, 1fr)" }}>
{BACKGROUND_COLORS.map((color) => (
<button
key={color}
type="button"
title={color}
onClick={() => {
editor.chain().focus().setBackgroundColor(color).run();
setBgColorMenuOpen(false);
}}
className={cn(
"w-4 h-4 border border-border/60 rounded-[2px] transition-transform hover:scale-110",
currentBgColor === color && "ring-1 ring-ring ring-offset-1"
)}
style={{ backgroundColor: color }}
/>
))}
</div>
<div className="h-px bg-border my-1.5" />
<button
type="button"
className="flex items-center gap-2 px-2 py-1 text-sm rounded hover:bg-accent text-start w-full"
onClick={() => {
editor.chain().focus().unsetBackgroundColor().run();
setBgColorMenuOpen(false);
}}
>
<RemoveFormatting className="w-4 h-4" /> {tToolbar("remove_background_color")}
</button>
</div>
)}
</div>
<ToolbarSeparator />
+3 -1
View File
@@ -730,7 +730,9 @@
"delete_table": "حذف الجدول",
"pick_size": "اختيار الحجم",
"font_size": "حجم الخط",
"font_size_default": "افتراضي"
"font_size_default": "افتراضي",
"background_color": "لون الخلفية",
"remove_background_color": "إزالة لون الخلفية"
},
"send_filing_warning": "تم الإرسال - لكن التنظيف بعد الإرسال فشل، وقد تبقى مسودة قديمة."
},
+3 -1
View File
@@ -730,7 +730,9 @@
"delete_table": "Elimina la taula",
"pick_size": "Tria la mida",
"font_size": "Mida de la lletra",
"font_size_default": "Per defecte"
"font_size_default": "Per defecte",
"background_color": "Color de fons",
"remove_background_color": "Elimina el color de fons"
},
"send_filing_warning": "Enviat, però la neteja posterior a l'enviament ha fallat; és possible que quedi un esborrany obsolet."
},
+3 -1
View File
@@ -730,7 +730,9 @@
"delete_table": "Odstranit tabulku",
"pick_size": "Vybrat velikost",
"font_size": "Velikost písma",
"font_size_default": "Výchozí"
"font_size_default": "Výchozí",
"background_color": "Barva pozadí",
"remove_background_color": "Odebrat barvu pozadí"
},
"send_filing_warning": "Odesláno - ale následný úklid selhal, může zůstat zastaralý koncept."
},
+3 -1
View File
@@ -730,7 +730,9 @@
"delete_table": "Slet tabel",
"pick_size": "Vælg størrelse",
"font_size": "Skriftstørrelse",
"font_size_default": "Standard"
"font_size_default": "Standard",
"background_color": "Baggrundsfarve",
"remove_background_color": "Fjern baggrundsfarve"
},
"send_filing_warning": "Sendt - men oprydningen bagefter mislykkedes, en forældet kladde kan blive stående."
},
+3 -1
View File
@@ -730,7 +730,9 @@
"delete_table": "Tabelle löschen",
"pick_size": "Größe wählen",
"font_size": "Schriftgröße",
"font_size_default": "Standard"
"font_size_default": "Standard",
"background_color": "Hintergrundfarbe",
"remove_background_color": "Hintergrundfarbe entfernen"
},
"send_filing_warning": "Gesendet - aber das Aufräumen danach schlug fehl, evtl. bleibt ein alter Entwurf sichtbar."
},
+3 -1
View File
@@ -730,7 +730,9 @@
"delete_table": "Delete table",
"pick_size": "Pick size",
"font_size": "Font size",
"font_size_default": "Default"
"font_size_default": "Default",
"background_color": "Background color",
"remove_background_color": "Remove background color"
},
"send_filing_warning": "Sent - but the post-send cleanup failed, a stale draft may remain."
},
+3 -1
View File
@@ -730,7 +730,9 @@
"delete_table": "Eliminar tabla",
"pick_size": "Elegir tamaño",
"font_size": "Tamaño de fuente",
"font_size_default": "Predeterminado"
"font_size_default": "Predeterminado",
"background_color": "Color de fondo",
"remove_background_color": "Quitar color de fondo"
},
"send_filing_warning": "Enviado - pero la limpieza posterior falló, puede quedar un borrador obsoleto."
},
+3 -1
View File
@@ -730,7 +730,9 @@
"delete_table": "حذف جدول",
"pick_size": "انتخاب اندازه",
"font_size": "اندازه فونت",
"font_size_default": "پیش‌فرض"
"font_size_default": "پیش‌فرض",
"background_color": "رنگ پس‌زمینه",
"remove_background_color": "حذف رنگ پس‌زمینه"
},
"send_filing_warning": "ارسال شد - اما پاک‌سازی پس از ارسال ناموفق بود، ممکن است پیش‌نویس قدیمی باقی بماند."
},
+3 -1
View File
@@ -730,7 +730,9 @@
"delete_table": "Supprimer le tableau",
"pick_size": "Choisir la taille",
"font_size": "Taille de police",
"font_size_default": "Par défaut"
"font_size_default": "Par défaut",
"background_color": "Couleur d'arrière-plan",
"remove_background_color": "Supprimer la couleur d'arrière-plan"
},
"send_filing_warning": "Envoyé - mais le nettoyage après envoi a échoué, un ancien brouillon peut subsister."
},
+3 -1
View File
@@ -695,7 +695,9 @@
"delete_table": "מחיקת טבלה",
"pick_size": "בחירת גודל",
"font_size": "גודל גופן",
"font_size_default": "ברירת מחדל"
"font_size_default": "ברירת מחדל",
"background_color": "צבע רקע",
"remove_background_color": "הסר צבע רקע"
},
"send_filing_warning": "נשלח - אך הניקוי שלאחר השליחה נכשל, ייתכן שתישאר טיוטה ישנה."
},
+3 -1
View File
@@ -730,7 +730,9 @@
"delete_table": "Táblázat törlése",
"pick_size": "Méret kiválasztása",
"font_size": "Betűméret",
"font_size_default": "Alapértelmezett"
"font_size_default": "Alapértelmezett",
"background_color": "Háttérszín",
"remove_background_color": "Háttérszín eltávolítása"
},
"send_filing_warning": "Elküldve - de az utólagos rendrakás nem sikerült, egy elavult piszkozat megmaradhat."
},
+3 -1
View File
@@ -730,7 +730,9 @@
"delete_table": "Elimina tabella",
"pick_size": "Scegli dimensione",
"font_size": "Dimensione carattere",
"font_size_default": "Predefinita"
"font_size_default": "Predefinita",
"background_color": "Colore di sfondo",
"remove_background_color": "Rimuovi colore di sfondo"
},
"send_filing_warning": "Inviato - ma la pulizia successiva non è riuscita, potrebbe restare una bozza obsoleta."
},
+3 -1
View File
@@ -730,7 +730,9 @@
"delete_table": "表を削除",
"pick_size": "サイズを選択",
"font_size": "文字サイズ",
"font_size_default": "デフォルト"
"font_size_default": "デフォルト",
"background_color": "背景色",
"remove_background_color": "背景色を削除"
},
"send_filing_warning": "送信されましたが、送信後の整理に失敗しました。古い下書きが残る場合があります。"
},
+3 -1
View File
@@ -730,7 +730,9 @@
"delete_table": "표 삭제",
"pick_size": "크기 선택",
"font_size": "글자 크기",
"font_size_default": "기본값"
"font_size_default": "기본값",
"background_color": "배경색",
"remove_background_color": "배경색 제거"
},
"send_filing_warning": "보냈지만 전송 후 정리에 실패했습니다. 오래된 임시 보관 메일이 남아 있을 수 있습니다."
},
+3 -1
View File
@@ -730,7 +730,9 @@
"delete_table": "Dzēst tabulu",
"pick_size": "Izvēlēties izmēru",
"font_size": "Fonta izmērs",
"font_size_default": "Noklusējums"
"font_size_default": "Noklusējums",
"background_color": "Fona krāsa",
"remove_background_color": "Noņemt fona krāsu"
},
"send_filing_warning": "Nosūtīts - bet pēcapstrāde neizdevās, var palikt novecojis melnraksts."
},
+3 -1
View File
@@ -730,7 +730,9 @@
"delete_table": "Хүснэгт устгах",
"pick_size": "Хэмжээ сонгох",
"font_size": "Фонтын хэмжээ",
"font_size_default": "Үндсэн"
"font_size_default": "Үндсэн",
"background_color": "Дэвсгэр өнгө",
"remove_background_color": "Дэвсгэр өнгийг арилгах"
},
"send_filing_warning": "Илгээсэн - гэхдээ илгээсний дараах цэвэрлэгээ амжилтгүй болсон тул хуучирсан ноорог үлдэж болзошгүй."
},
+3 -1
View File
@@ -730,7 +730,9 @@
"delete_table": "Tabel verwijderen",
"pick_size": "Grootte kiezen",
"font_size": "Lettergrootte",
"font_size_default": "Standaard"
"font_size_default": "Standaard",
"background_color": "Achtergrondkleur",
"remove_background_color": "Achtergrondkleur verwijderen"
},
"send_filing_warning": "Verzonden - maar het opruimen daarna is mislukt, mogelijk blijft een oud concept staan."
},
+3 -1
View File
@@ -730,7 +730,9 @@
"delete_table": "Usuń tabelę",
"pick_size": "Wybierz rozmiar",
"font_size": "Rozmiar czcionki",
"font_size_default": "Domyślny"
"font_size_default": "Domyślny",
"background_color": "Kolor tła",
"remove_background_color": "Usuń kolor tła"
},
"send_filing_warning": "Wysłano - ale późniejsze porządkowanie nie powiodło się, może pozostać nieaktualna wersja robocza."
},
+3 -1
View File
@@ -730,7 +730,9 @@
"delete_table": "Excluir tabela",
"pick_size": "Escolher tamanho",
"font_size": "Tamanho da fonte",
"font_size_default": "Padrão"
"font_size_default": "Padrão",
"background_color": "Cor de fundo",
"remove_background_color": "Remover cor de fundo"
},
"send_filing_warning": "Enviado - mas a limpeza posterior falhou, um rascunho antigo pode permanecer."
},
+3 -1
View File
@@ -730,7 +730,9 @@
"delete_table": "Șterge tabelul",
"pick_size": "Alege dimensiunea",
"font_size": "Dimensiune font",
"font_size_default": "Implicit"
"font_size_default": "Implicit",
"background_color": "Culoarea fundalului",
"remove_background_color": "Elimină culoarea fundalului"
},
"send_filing_warning": "Trimis - dar curățarea ulterioară a eșuat, poate rămâne o ciornă veche."
},
+3 -1
View File
@@ -730,7 +730,9 @@
"delete_table": "Удалить таблицу",
"pick_size": "Выбрать размер",
"font_size": "Размер шрифта",
"font_size_default": "По умолчанию"
"font_size_default": "По умолчанию",
"background_color": "Цвет фона",
"remove_background_color": "Убрать цвет фона"
},
"send_filing_warning": "Отправлено - но последующая очистка не удалась, может остаться устаревший черновик."
},
+3 -1
View File
@@ -730,7 +730,9 @@
"delete_table": "Odstrániť tabuľku",
"pick_size": "Vybrať veľkosť",
"font_size": "Veľkosť písma",
"font_size_default": "Predvolené"
"font_size_default": "Predvolené",
"background_color": "Farba pozadia",
"remove_background_color": "Odstrániť farbu pozadia"
},
"send_filing_warning": "Odoslané - ale následné upratovanie zlyhalo, môže zostať zastaraný koncept."
},
+3 -1
View File
@@ -730,7 +730,9 @@
"delete_table": "Tabloyu sil",
"pick_size": "Boyut seç",
"font_size": "Yazı boyutu",
"font_size_default": "Varsayılan"
"font_size_default": "Varsayılan",
"background_color": "Arka plan rengi",
"remove_background_color": "Arka plan rengini kaldır"
},
"send_filing_warning": "Gönderildi - ancak sonrasındaki temizleme başarısız oldu, eski bir taslak kalabilir."
},
+3 -1
View File
@@ -730,7 +730,9 @@
"delete_table": "Видалити таблицю",
"pick_size": "Вибрати розмір",
"font_size": "Розмір шрифту",
"font_size_default": "За замовчуванням"
"font_size_default": "За замовчуванням",
"background_color": "Колір фону",
"remove_background_color": "Прибрати колір фону"
},
"send_filing_warning": "Надіслано - але подальше очищення не вдалося, може залишитися застаріла чернетка."
},
+3 -1
View File
@@ -730,7 +730,9 @@
"delete_table": "删除表格",
"pick_size": "选择大小",
"font_size": "字号",
"font_size_default": "默认"
"font_size_default": "默认",
"background_color": "背景颜色",
"remove_background_color": "移除背景颜色"
},
"send_filing_warning": "已发送,但发送后的清理失败,可能会残留旧草稿。"
},