mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-25 17:34:52 +10:00
e96a51f31c
* 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>
156 lines
4.7 KiB
TypeScript
156 lines
4.7 KiB
TypeScript
import type { HTMLElement, Node } from "node-html-parser";
|
||
import { NodeType, parse } from "node-html-parser";
|
||
import { isDarkColor } from "@reactive-resume/utils/color";
|
||
|
||
export const richTextMarkClassName = "rr-pdf-mark";
|
||
|
||
const inlineTags = new Set([
|
||
"a",
|
||
"abbr",
|
||
"b",
|
||
"br",
|
||
"button",
|
||
"cite",
|
||
"code",
|
||
"dfn",
|
||
"em",
|
||
"i",
|
||
"label",
|
||
"q",
|
||
"s",
|
||
"span",
|
||
"strong",
|
||
"sub",
|
||
"sup",
|
||
"u",
|
||
]);
|
||
|
||
const getTagName = (node: Node) => node.rawTagName.toLowerCase();
|
||
|
||
const hasBlockDescendant = (node: Node): boolean =>
|
||
node.childNodes.some((child) => child.nodeType === NodeType.ELEMENT_NODE && !isInlineNode(child));
|
||
|
||
const mergeClassNames = (...classNames: (string | undefined)[]): string => {
|
||
const uniqueClassNames = new Set<string>();
|
||
|
||
for (const className of classNames) {
|
||
if (!className) continue;
|
||
|
||
for (const part of className.split(/\s+/)) {
|
||
if (part) uniqueClassNames.add(part);
|
||
}
|
||
}
|
||
|
||
return [...uniqueClassNames].join(" ");
|
||
};
|
||
|
||
const normalizeMarkElements = (root: ReturnType<typeof parse>) => {
|
||
for (const mark of root.querySelectorAll("mark")) {
|
||
const dataColor = mark.getAttribute("data-color");
|
||
|
||
mark.tagName = "span";
|
||
mark.setAttribute("class", mergeClassNames(mark.getAttribute("class"), richTextMarkClassName));
|
||
|
||
// Preserve custom highlight color as inline background-color for react-pdf-html.
|
||
// Legacy marks without data-color fall back to the .rr-pdf-mark stylesheet (yellow).
|
||
if (dataColor) {
|
||
const existingStyle = mark.getAttribute("style") ?? "";
|
||
let inlineStyle = `background-color: ${dataColor}`;
|
||
if (isDarkColor(dataColor)) inlineStyle += "; color: #ffffff";
|
||
mark.setAttribute("style", existingStyle ? `${existingStyle}; ${inlineStyle}` : inlineStyle);
|
||
}
|
||
}
|
||
};
|
||
|
||
const isMeaningfulNode = (node: Node): boolean =>
|
||
node.nodeType !== NodeType.TEXT_NODE || node.toString().trim().length > 0;
|
||
|
||
const isElement = (node: Node): node is HTMLElement => node.nodeType === NodeType.ELEMENT_NODE;
|
||
|
||
const unwrapSingleParagraphListItems = (root: ReturnType<typeof parse>) => {
|
||
for (const listItem of root.querySelectorAll("li")) {
|
||
const meaningfulChildren = listItem.childNodes.filter(isMeaningfulNode);
|
||
if (meaningfulChildren.length !== 1) continue;
|
||
|
||
const child = meaningfulChildren[0];
|
||
if (!child || !isElement(child) || getTagName(child) !== "p") continue;
|
||
|
||
listItem.innerHTML = child.innerHTML;
|
||
}
|
||
};
|
||
|
||
const isInlineNode = (node: Node): boolean => {
|
||
if (node.nodeType === NodeType.TEXT_NODE || node.nodeType === NodeType.COMMENT_NODE) return true;
|
||
if (node.nodeType !== NodeType.ELEMENT_NODE) return false;
|
||
|
||
return inlineTags.has(getTagName(node)) && !hasBlockDescendant(node);
|
||
};
|
||
|
||
// Allow optional leading whitespace + LRM/RLM marks before the bullet character.
|
||
const PSEUDO_BULLET_LEAD = /^[\s]*[-•*]\s+/;
|
||
|
||
const stripEmptyInlineWrappers = (html: string): string =>
|
||
html.replace(/<(strong|b|em|i|u|span)\b[^>]*>\s*<\/\1>/gi, "");
|
||
|
||
// Treat a bare <br> or one wrapped in an inline tag (e.g. `<strong><br></strong>` from
|
||
// the editor) as the segment separator.
|
||
const splitByBreaks = (html: string): string[] =>
|
||
html.split(/(?:<(?:strong|b|em|i|u|span)\b[^>]*>\s*<br\b[^>]*\/?>\s*<\/(?:strong|b|em|i|u|span)>)|<br\b[^>]*\/?>/gi);
|
||
|
||
const tryConvertPseudoBulletParagraph = (paragraphInnerHtml: string): string | null => {
|
||
const cleaned = stripEmptyInlineWrappers(paragraphInnerHtml);
|
||
if (!/<br\b/i.test(cleaned)) return null;
|
||
|
||
const segments: string[] = [];
|
||
for (const segment of splitByBreaks(cleaned)) {
|
||
const trimmed = segment.trim();
|
||
if (trimmed.length > 0) segments.push(trimmed);
|
||
}
|
||
|
||
if (segments.length < 2) return null;
|
||
if (!segments.every((segment) => PSEUDO_BULLET_LEAD.test(segment))) return null;
|
||
|
||
const items = segments.map((segment) => segment.replace(PSEUDO_BULLET_LEAD, ""));
|
||
|
||
return `<ul>${items.map((item) => `<li>${item}</li>`).join("")}</ul>`;
|
||
};
|
||
|
||
export const convertPseudoBulletParagraphs = (html: string): string =>
|
||
html.replace(/<p\b([^>]*)>([\s\S]*?)<\/p>/gi, (full, _attrs, inner) => {
|
||
const converted = tryConvertPseudoBulletParagraph(inner);
|
||
return converted ?? full;
|
||
});
|
||
|
||
export const normalizeRichTextHtml = (html: string): string => {
|
||
const root = parse(html.trim(), { comment: false });
|
||
const normalized: string[] = [];
|
||
let inlineNodes: string[] = [];
|
||
|
||
normalizeMarkElements(root);
|
||
unwrapSingleParagraphListItems(root);
|
||
|
||
const flushInlineNodes = () => {
|
||
const inlineHtml = inlineNodes.join("").trim();
|
||
|
||
if (inlineHtml) normalized.push(`<p>${inlineHtml}</p>`);
|
||
|
||
inlineNodes = [];
|
||
};
|
||
|
||
for (const node of root.childNodes) {
|
||
const nodeHtml = node.toString();
|
||
|
||
if (isInlineNode(node)) {
|
||
inlineNodes.push(nodeHtml);
|
||
continue;
|
||
}
|
||
|
||
flushInlineNodes();
|
||
normalized.push(nodeHtml);
|
||
}
|
||
|
||
flushInlineNodes();
|
||
|
||
return normalized.join("");
|
||
};
|