import type { CSSProperties } from "react"; import { parseAgentMentionHref, parseProjectMentionHref } from "@paperclipai/shared"; import { getAgentIcon } from "./agent-icons"; import { hexToRgb, pickTextColorForPillBg } from "./color-contrast"; export type ParsedMentionChip = | { kind: "agent"; agentId: string; icon: string | null; } | { kind: "project"; projectId: string; color: string | null; }; const iconMaskCache = new Map(); export function parseMentionChipHref(href: string): ParsedMentionChip | null { const agent = parseAgentMentionHref(href); if (agent) { return { kind: "agent", agentId: agent.agentId, icon: agent.icon, }; } const project = parseProjectMentionHref(href); if (project) { return { kind: "project", projectId: project.projectId, color: project.color, }; } return null; } export function mentionChipInlineStyle(mention: ParsedMentionChip): CSSProperties | undefined { const style: CSSProperties & Record = {}; if (mention.kind === "project" && mention.color) { const projectStyle = projectMentionColors(mention.color); Object.assign(style, projectStyle); style["--paperclip-mention-project-color"] = mention.color; } if (mention.kind === "agent") { const iconMask = buildAgentIconMask(mention.icon); if (iconMask) { style["--paperclip-mention-icon-mask"] = iconMask; } } return Object.keys(style).length > 0 ? (style as CSSProperties) : undefined; } export function applyMentionChipDecoration(element: HTMLElement, mention: ParsedMentionChip) { clearMentionChipDecoration(element); element.dataset.mentionKind = mention.kind; element.setAttribute("contenteditable", "false"); element.classList.add("paperclip-mention-chip", `paperclip-mention-chip--${mention.kind}`); if (mention.kind === "project") { element.classList.add("paperclip-project-mention-chip"); } const style = mentionChipInlineStyle(mention); if (!style) return; for (const [key, value] of Object.entries(style)) { if (typeof value === "string") { if (key.startsWith("--")) { element.style.setProperty(key, value); } else { (element.style as CSSStyleDeclaration & Record)[key] = value; } } } } export function clearMentionChipDecoration(element: HTMLElement) { delete element.dataset.mentionKind; element.classList.remove( "paperclip-mention-chip", "paperclip-mention-chip--agent", "paperclip-mention-chip--project", "paperclip-project-mention-chip", ); element.removeAttribute("contenteditable"); element.style.removeProperty("border-color"); element.style.removeProperty("background-color"); element.style.removeProperty("color"); element.style.removeProperty("--paperclip-mention-project-color"); element.style.removeProperty("--paperclip-mention-icon-mask"); } function projectMentionColors(color: string): Pick { const rgb = hexToRgb(color); if (!rgb) return {}; return { borderColor: color, backgroundColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.22)`, color: pickTextColorForPillBg(color), }; } function buildAgentIconMask(iconName: string | null): string | null { const cacheKey = iconName ?? "__default__"; const cached = iconMaskCache.get(cacheKey); if (cached) return cached; const Icon = getAgentIcon(iconName); const iconNode = resolveLucideIconNode(Icon); if (!Array.isArray(iconNode) || iconNode.length === 0) return null; const body = iconNode.map(([tag, attrs]) => { const attrString = Object.entries(attrs) .filter(([key]) => key !== "key") .map(([key, value]) => `${key}="${escapeAttribute(String(value))}"`) .join(" "); return `<${tag}${attrString ? ` ${attrString}` : ""}>`; }).join(""); const svg = `${body}`; const url = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`; iconMaskCache.set(cacheKey, url); return url; } function resolveLucideIconNode( icon: unknown, ): Array<[string, Record]> | null { const staticIconNode = ( icon as { iconNode?: Array<[string, Record]>; } ).iconNode; if (Array.isArray(staticIconNode) && staticIconNode.length > 0) { return staticIconNode; } const render = ( icon as { render?: (props: Record, ref: unknown) => { props?: { iconNode?: Array<[string, Record]> }; } | null; } ).render; const rendered = typeof render === "function" ? render({}, null) : null; const renderedIconNode = rendered?.props?.iconNode; return Array.isArray(renderedIconNode) && renderedIconNode.length > 0 ? renderedIconNode : null; } function escapeAttribute(value: string): string { return value .replaceAll("&", "&") .replaceAll('"', """) .replaceAll("<", "<") .replaceAll(">", ">"); }