feat(editor): add multicolor highlight with auto-contrast text (#3110)

* feat(editor): add multicolor highlight with auto-contrast text

Enable the Tiptap Highlight extension in multicolor mode, replacing the
single-color yellow toggle with a full color picker (16 presets + custom).
When the chosen highlight color is perceptually dark, text inside the mark
automatically renders white for readability.

Changes span the full pipeline:
- Editor: ColorPicker UI, extended renderHTML for contrast detection
- PDF: normalizeMarkElements preserves data-color as inline style
- DOCX: mergeStyle reads actual background-color from <mark>
- Utils: new isDarkColor() luminance helper

Backward-compatible: legacy <mark> without data-color still renders yellow.

Resolves #3109

* fix: handle multicolor highlight edge cases

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
JamesGoslings
2026-06-01 20:58:15 +08:00
committed by GitHub
parent 1507d869c7
commit e96a51f31c
63 changed files with 565 additions and 179 deletions
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { resolveHighlightToolbarState } from "./rich-input";
describe("resolveHighlightToolbarState", () => {
it("shows legacy colorless highlights as default yellow and clearable", () => {
expect(resolveHighlightToolbarState(true, null)).toEqual({
visibleHighlightColor: "rgba(255, 255, 0, 1)",
canClearHighlight: true,
});
});
it("does not show or clear a highlight when no highlight mark is active", () => {
expect(resolveHighlightToolbarState(false, null)).toEqual({
visibleHighlightColor: undefined,
canClearHighlight: false,
});
});
});
+85 -14
View File
@@ -62,12 +62,14 @@ import {
} from "@reactive-resume/ui/components/dropdown-menu";
import { PopoverHeader, PopoverTitle, PopoverTrigger } from "@reactive-resume/ui/components/popover";
import { Toggle } from "@reactive-resume/ui/components/toggle";
import { isDarkColor } from "@reactive-resume/utils/color";
import { cn } from "@reactive-resume/utils/style";
import { usePrompt } from "@/hooks/use-prompt";
import { isRTL } from "@/libs/locale";
import { ColorPicker } from "./color-picker";
const defaultTextColor = "rgba(0, 0, 0, 1)";
const defaultHighlightColor = "rgba(255, 255, 0, 1)";
const extensions = [
StarterKit.configure({
@@ -86,7 +88,15 @@ const extensions = [
}),
TextStyle,
Color,
Highlight.configure(),
Highlight.configure({ multicolor: true }).extend({
renderHTML({ HTMLAttributes }) {
const color = HTMLAttributes["data-color"] as string | undefined;
if (color && isDarkColor(color)) {
HTMLAttributes.style = `${HTMLAttributes.style ?? ""}; color: #ffffff`;
}
return ["mark", HTMLAttributes, 0];
},
}),
TextAlign.configure({ types: ["heading", "paragraph", "listItem"] }),
TableKit.configure(),
];
@@ -223,10 +233,12 @@ function useEditorToolbarState(editor: Editor) {
canStrike: ctx.editor.can().chain().toggleStrike().run() ?? false,
toggleStrike: () => ctx.editor.chain().focus().toggleStrike().run(),
// Highlight
// Highlight Color
isHighlight: ctx.editor.isActive("highlight") ?? false,
canHighlight: ctx.editor.can().chain().toggleHighlight().run() ?? false,
toggleHighlight: () => ctx.editor.chain().focus().toggleHighlight().run(),
highlightColor: (ctx.editor.getAttributes("highlight").color as string | undefined) ?? null,
canHighlightColor: ctx.editor.can().chain().toggleHighlight().run() ?? false,
setHighlightColor: (color: string) => ctx.editor.chain().focus().toggleHighlight({ color }).run(),
unsetHighlightColor: () => ctx.editor.chain().focus().unsetHighlight().run(),
// Text Color
textColor: (ctx.editor.getAttributes("textStyle").color as string | undefined) ?? null,
@@ -372,6 +384,12 @@ function useEditorToolbarState(editor: Editor) {
type EditorToolbarState = ReturnType<typeof useEditorToolbarState>;
export function resolveHighlightToolbarState(isHighlight: boolean, highlightColor: string | null) {
const visibleHighlightColor = highlightColor ?? (isHighlight ? defaultHighlightColor : undefined);
return { visibleHighlightColor, canClearHighlight: isHighlight };
}
type EditorToolbarProps = {
editor: Editor;
isFullscreen: boolean;
@@ -384,6 +402,11 @@ function EditorToolbar({ editor, isFullscreen }: EditorToolbarProps) {
}
function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
const { visibleHighlightColor, canClearHighlight } = resolveHighlightToolbarState(
state.isHighlight,
state.highlightColor,
);
return (
<div className="flex flex-wrap items-center gap-y-0.5 rounded-md rounded-b-none border border-b-0">
<Toggle
@@ -434,17 +457,65 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
<TextStrikethroughIcon className="size-3.5" />
</Toggle>
<Toggle
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
className="rounded-none"
title={t`Highlight`}
pressed={state.isHighlight}
disabled={!state.canHighlight}
onPressedChange={state.toggleHighlight}
<ColorPicker
defaultValue={defaultHighlightColor}
value={visibleHighlightColor}
onChange={state.setHighlightColor}
trigger={
<PopoverTrigger
render={
<Button
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
variant="ghost"
className={cn("rounded-none px-2", state.isHighlight && "bg-muted text-foreground")}
title={t`Highlight`}
disabled={!state.canHighlightColor}
>
<span className="flex flex-col items-center leading-none">
<HighlighterCircleIcon className="size-3.5" />
<span
className="mt-0.5 h-0.5 w-3 rounded-full"
style={{ backgroundColor: visibleHighlightColor ?? "currentColor" }}
/>
</span>
</Button>
}
/>
}
>
<HighlighterCircleIcon className="size-3.5" />
</Toggle>
<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 shadow-xs"
style={{ backgroundColor: visibleHighlightColor ?? defaultHighlightColor }}
>
<HighlighterCircleIcon className="size-4" />
</span>
<div className="flex flex-col gap-0.5">
<PopoverTitle>
<Trans>Highlight Color</Trans>
</PopoverTitle>
<span className="text-muted-foreground text-xs">
<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.unsetHighlightColor}
disabled={!canClearHighlight}
>
<Trans comment="Clear the highlight color">Clear</Trans>
</Button>
</PopoverHeader>
</ColorPicker>
<ColorPicker
defaultValue={defaultTextColor}