From ddb455f897b4ab05140ffe0c7e0237dbbcbb684d Mon Sep 17 00:00:00 2001 From: jkemmererupgrade Date: Fri, 22 May 2026 14:10:58 -0600 Subject: [PATCH] feat(icons): add built-in Lucide icon library for node styling Stores icon selections as symbolic lucide: references rather than data URIs, so config files remain human-readable and the picker can highlight the current selection without reverse-engineering the blob. Resolution happens at render time via two paths: - React components (VertexIcon): useResolvedIconUrl hook backed by React Query (staleTime: Infinity) converts lucide: to a base64 SVG data URI on first use and caches the result. - Cytoscape pipeline (renderNode): getLucideSvgString resolves directly from the lucide-react dynamic-import map, skipping any fetch round-trip. Custom-uploaded icons (data: URIs and plain URLs) are unchanged -- they pass through both resolvers untouched. The new IconPicker component (searchable popover, 8-column grid, 50-icon default window) is wired into NodeStyleDialog alongside the existing file upload button so users can choose a Lucide icon or keep uploading their own. Also fixes a pre-existing lint-staged misconfiguration where the root oxlint --fix task matched vitest.config.ts, which oxlint then ignored (matching its own ignorePatterns), causing exit code 1. The root lint-staged config now mirrors the oxlint ignorePatterns by routing *.config.* files to oxfmt only and restricting oxlint to packages/. Pre-flight: pnpm checks, pnpm test (1738/1738), pnpm coverage all pass. This is Slice 1 of the 3-slice split of #1589 per @kmcginnes review. --- .gitignore | 3 + docs/features/graph-view.md | 2 +- package.json | 3 +- packages/graph-explorer/package.json | 1 - .../src/components/IconPicker.test.tsx | 153 ++++++++++++++++ .../src/components/IconPicker.tsx | 157 ++++++++++++++++ .../src/components/VertexIcon.tsx | 6 +- .../graph-explorer/src/components/index.ts | 2 + .../modules/GraphViewer/renderNode.test.ts | 33 ++++ .../src/modules/GraphViewer/renderNode.tsx | 10 + .../modules/NodesStyling/NodeStyleDialog.tsx | 7 + .../src/utils/lucideIconUrl.test.ts | 172 ++++++++++++++++++ .../graph-explorer/src/utils/lucideIconUrl.ts | 141 ++++++++++++++ .../src/utils/useResolvedIconUrl.test.tsx | 47 +++++ .../src/utils/useResolvedIconUrl.ts | 22 +++ vitest.config.ts | 2 +- 16 files changed, 755 insertions(+), 6 deletions(-) create mode 100644 packages/graph-explorer/src/components/IconPicker.test.tsx create mode 100644 packages/graph-explorer/src/components/IconPicker.tsx create mode 100644 packages/graph-explorer/src/utils/lucideIconUrl.test.ts create mode 100644 packages/graph-explorer/src/utils/lucideIconUrl.ts create mode 100644 packages/graph-explorer/src/utils/useResolvedIconUrl.test.tsx create mode 100644 packages/graph-explorer/src/utils/useResolvedIconUrl.ts diff --git a/.gitignore b/.gitignore index 38fd6570d..55adeb675 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ **/dist/ **/.env.local +# IntelliJ IDEA module files +*.iml + # TypeScript build cache manifests *.tsbuildinfo diff --git a/docs/features/graph-view.md b/docs/features/graph-view.md index ee09b9e9c..77a053361 100644 --- a/docs/features/graph-view.md +++ b/docs/features/graph-view.md @@ -80,7 +80,7 @@ Each node type can be customized in a variety of ways. - **Display label** allows you to change how the node label (or rdf:type) is represented - **Display name attribute** allows you to choose the attribute on the node that is used to uniquely label the node in the graph visualization and search - **Display description attribute** allows you to choose the attribute on the node that is used to describe the node in search -- **Custom symbol** can be uploaded in the form of an SVG icon +- **Icon** can be picked from the built-in Lucide library via the **Browse** button, or uploaded as a custom SVG/raster image. Picked Lucide icons are stored as `lucide:` references and resolved at render time, so the picker highlights the currently selected icon when you reopen the dialog. - **Colors and borders** can be customized to visually distinguish from other node types ### Edge Styling Panel diff --git a/package.json b/package.json index 299dee31d..1f79a0c2a 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ }, "lint-staged": { "!(**/*.{js,ts,tsx})": "oxfmt --no-error-on-unmatched-pattern", - "**/*.{js,ts,tsx}": [ + "**/*.config.{js,ts,mjs}": "oxfmt", + "packages/**/*.{js,ts,tsx}": [ "oxlint --fix", "oxfmt" ] diff --git a/packages/graph-explorer/package.json b/packages/graph-explorer/package.json index 689477092..5e7bc398e 100644 --- a/packages/graph-explorer/package.json +++ b/packages/graph-explorer/package.json @@ -107,7 +107,6 @@ }, "lint-staged": { "*.{ts,tsx}": [ - "eslint --fix", "oxfmt" ] }, diff --git a/packages/graph-explorer/src/components/IconPicker.test.tsx b/packages/graph-explorer/src/components/IconPicker.test.tsx new file mode 100644 index 000000000..4b54061fa --- /dev/null +++ b/packages/graph-explorer/src/components/IconPicker.test.tsx @@ -0,0 +1,153 @@ +// @vitest-environment happy-dom +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { IconPicker } from "./IconPicker"; + +describe("IconPicker", () => { + it("should render Browse button", () => { + render(); + expect(screen.getByRole("button", { name: /browse/i })).toBeInTheDocument(); + }); + + it("should open popover with search input on click", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + + expect(screen.getByPlaceholderText("Search icons...")).toBeInTheDocument(); + }); + + it("should show icons in the grid", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + + // Wait for at least some icon buttons to appear in the grid + await waitFor(() => { + const iconButtons = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title !== ""); + expect(iconButtons.length).toBeGreaterThan(0); + }); + }); + + it("should filter icons when searching", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + const searchInput = screen.getByPlaceholderText("Search icons..."); + + await user.type(searchInput, "user"); + + await waitFor(() => { + const iconButtons = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title.includes("user")); + expect(iconButtons.length).toBeGreaterThan(0); + }); + }); + + it("should show no results message for invalid search", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + const searchInput = screen.getByPlaceholderText("Search icons..."); + + await user.type(searchInput, "zzzznotanicon"); + + expect(screen.getByText("No icons found")).toBeInTheDocument(); + }); + + it("should call onSelect with lucide: reference when icon is clicked", async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + + await waitFor(() => { + const iconButtons = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title !== ""); + expect(iconButtons.length).toBeGreaterThan(0); + }); + + const firstIcon = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title !== "")[0]; + const iconName = firstIcon.getAttribute("title"); + await user.click(firstIcon); + + expect(onSelect).toHaveBeenCalledWith( + `lucide:${iconName}`, + "image/svg+xml", + ); + }); + + it("should highlight the icon matching currentIconUrl", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + + await waitFor(() => { + const airplayBtn = screen + .getAllByRole("button") + .find(btn => btn.title === "airplay"); + expect(airplayBtn).toBeDefined(); + expect(airplayBtn).toHaveAttribute("aria-pressed", "true"); + }); + }); + + it("should not highlight any icon when currentIconUrl is not a lucide ref", async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole("button", { name: /browse/i })); + + await waitFor(() => { + const iconButtons = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title !== ""); + expect(iconButtons.length).toBeGreaterThan(0); + for (const btn of iconButtons) { + expect(btn).toHaveAttribute("aria-pressed", "false"); + } + }); + }); + + it("should close popover after selecting an icon", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + + await waitFor(() => { + const iconButtons = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title !== ""); + expect(iconButtons.length).toBeGreaterThan(0); + }); + + const firstIcon = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title !== "")[0]; + await user.click(firstIcon); + + await waitFor(() => { + expect( + screen.queryByPlaceholderText("Search icons..."), + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/graph-explorer/src/components/IconPicker.tsx b/packages/graph-explorer/src/components/IconPicker.tsx new file mode 100644 index 000000000..2508b9fbc --- /dev/null +++ b/packages/graph-explorer/src/components/IconPicker.tsx @@ -0,0 +1,157 @@ +import { SearchIcon } from "lucide-react"; +import dynamicIconImports from "lucide-react/dynamicIconImports"; +import { useEffect, useRef, useState } from "react"; + +import { cn } from "@/utils"; +import { + getLucideName, + lucideIconToDataUri, + toLucideIconRef, +} from "@/utils/lucideIconUrl"; + +import { Button, Input, Popover, PopoverContent, PopoverTrigger } from "."; + +const allIconNames = Object.keys(dynamicIconImports).sort(); + +const MAX_VISIBLE = 50; + +export function IconPicker({ + currentIconUrl, + onSelect, +}: { + /** + * The vertex's currently stored iconUrl. When this is a `lucide:` + * reference, the matching grid cell is highlighted to indicate the + * current selection. + */ + currentIconUrl?: string; + /** + * Called with the symbolic `lucide:` reference and the SVG MIME type. + * Resolution to a data URI happens at render time, not here. + */ + onSelect: (iconUrl: string, iconImageType: string) => void; +}) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const inputRef = useRef(null); + + const selectedName = getLucideName(currentIconUrl); + const filtered = filterIcons(search); + + function handleSelect(iconName: string) { + onSelect(toLucideIconRef(iconName), "image/svg+xml"); + setOpen(false); + setSearch(""); + } + + useEffect(() => { + if (open) { + const timer = setTimeout(() => inputRef.current?.focus(), 100); + return () => clearTimeout(timer); + } + }, [open]); + + return ( + + + + + + setSearch(e.target.value)} + className="h-8 text-sm" + /> +
+ {filtered.map(name => ( + + ))} + {filtered.length === 0 && ( +

+ No icons found +

+ )} +
+ {!search && ( +

+ Showing {MAX_VISIBLE} of {allIconNames.length} icons. Type to + search. +

+ )} +
+
+ ); +} + +function IconButton({ + name, + selected, + onSelect, +}: { + name: string; + selected: boolean; + onSelect: (name: string) => void; +}) { + const [src, setSrc] = useState(null); + + useEffect(() => { + let cancelled = false; + lucideIconToDataUri(name).then( + uri => { + if (!cancelled && uri) setSrc(uri); + }, + () => { + // Icon failed to load, leave as placeholder + }, + ); + return () => { + cancelled = true; + }; + }, [name]); + + return ( + + ); +} + +function filterIcons(search: string): string[] { + if (!search) return allIconNames.slice(0, MAX_VISIBLE); + const lower = search.toLowerCase(); + const results: string[] = []; + for (const name of allIconNames) { + if (name.includes(lower)) { + results.push(name); + if (results.length >= MAX_VISIBLE) break; + } + } + return results; +} diff --git a/packages/graph-explorer/src/components/VertexIcon.tsx b/packages/graph-explorer/src/components/VertexIcon.tsx index 8ad4122e2..c117c818c 100644 --- a/packages/graph-explorer/src/components/VertexIcon.tsx +++ b/packages/graph-explorer/src/components/VertexIcon.tsx @@ -8,6 +8,7 @@ import { type VertexType, } from "@/core"; import { cn } from "@/utils"; +import { useResolvedIconUrl } from "@/utils/useResolvedIconUrl"; import { SearchResultSymbol } from "./SearchResult"; @@ -19,11 +20,12 @@ interface Props { function VertexIcon({ vertexStyle, className, alt }: Props) { const altText = alt ?? `${vertexStyle.displayLabel ?? vertexStyle.type} icon`; + const resolvedSrc = useResolvedIconUrl(vertexStyle.iconUrl); if (vertexStyle.iconImageType === "image/svg+xml") { return ( { ), ); }); + + it("should resolve lucide: iconUrl from the icon library without fetching", async () => { + const node: VertexIconConfig = { + type: createRandomVertexType(), + color: createRandomColor(), + iconUrl: "lucide:user", + iconImageType: "image/svg+xml", + }; + + const result = await renderNode(client, node); + + expect(fetchMock).not.toBeCalled(); + expect(result).toBeDefined(); + expect(result?.slice(0, 24)).toEqual("data:image/svg+xml;utf8,"); + const decodedSvg = decodeSvg(result); + expect(decodedSvg).toContain(" { + const node: VertexIconConfig = { + type: createRandomVertexType(), + color: createRandomColor(), + iconUrl: "lucide:not-a-real-icon-name-xyz", + iconImageType: "image/svg+xml", + }; + + const result = await renderNode(client, node); + + expect(fetchMock).not.toBeCalled(); + expect(result).toBeNull(); + expect(vi.mocked(logger.error)).toHaveBeenCalledOnce(); + }); }); /** Wraps SVG string in another SVG element matching what is expected. */ diff --git a/packages/graph-explorer/src/modules/GraphViewer/renderNode.tsx b/packages/graph-explorer/src/modules/GraphViewer/renderNode.tsx index d2b0a0a2b..dca854d39 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/renderNode.tsx +++ b/packages/graph-explorer/src/modules/GraphViewer/renderNode.tsx @@ -7,6 +7,7 @@ import { import type { VertexTypeConfig } from "@/core"; import { logger } from "@/utils"; +import { getLucideName, getLucideSvgString } from "@/utils/lucideIconUrl"; export type VertexIconConfig = Pick< VertexTypeConfig, @@ -17,6 +18,15 @@ const iconQueryOptions = (url: string) => queryOptions({ queryKey: ["icon", url], queryFn: async () => { + const lucideName = getLucideName(url); + if (lucideName) { + logger.debug("Resolving lucide icon", lucideName); + const svg = await getLucideSvgString(lucideName); + if (svg === null) { + throw new Error(`Unknown Lucide icon: ${lucideName}`); + } + return svg; + } logger.debug("Fetching icon", url); const response = await fetch(url); return await response.text(); diff --git a/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx b/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx index 41399f3c1..78073c414 100644 --- a/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx +++ b/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx @@ -10,6 +10,7 @@ import { FieldLabel, FieldSet, FileButton, + IconPicker, Input, Select, SelectContent, @@ -208,6 +209,12 @@ function Content({ vertexType }: { vertexType: VertexType }) { Icon
+ + setVertexStyle({ iconUrl, iconImageType }) + } + /> { diff --git a/packages/graph-explorer/src/utils/lucideIconUrl.test.ts b/packages/graph-explorer/src/utils/lucideIconUrl.test.ts new file mode 100644 index 000000000..1266e860d --- /dev/null +++ b/packages/graph-explorer/src/utils/lucideIconUrl.test.ts @@ -0,0 +1,172 @@ +import type dynamicIconImports from "lucide-react/dynamicIconImports"; + +import { + getLucideName, + getLucideSvgString, + isLucideIconRef, + LUCIDE_PREFIX, + lucideIconToDataUri, + resolveIconUrl, + toLucideIconRef, +} from "./lucideIconUrl"; + +vi.mock("lucide-react/dynamicIconImports", async () => { + const actual = await vi.importActual("lucide-react/dynamicIconImports"); + return { + ...actual, + default: { + ...(actual as { default: typeof dynamicIconImports }).default, + // Icon that loads but has no __iconNode + "missing-icon-node": () => Promise.resolve({ default: {} }), + // Icon that throws on import + "throwing-icon": () => Promise.reject(new Error("import failed")), + }, + }; +}); + +describe("lucideIconToDataUri", () => { + it("should return a data URI for a valid icon name", async () => { + const result = await lucideIconToDataUri("user"); + expect(result).not.toBeNull(); + expect(result).toMatch(/^data:image\/svg\+xml;base64,/); + }); + + it("should return a valid SVG when decoded", async () => { + const result = await lucideIconToDataUri("user"); + expect(result).not.toBeNull(); + + const base64 = result!.replace("data:image/svg+xml;base64,", ""); + const svg = atob(base64); + expect(svg).toContain(""); + }); + + it("should return null for an unknown icon name", async () => { + const result = await lucideIconToDataUri("not-a-real-icon-name"); + expect(result).toBeNull(); + }); + + it("should return null for an empty string", async () => { + const result = await lucideIconToDataUri(""); + expect(result).toBeNull(); + }); + + it("should handle kebab-case icon names", async () => { + const result = await lucideIconToDataUri("log-in"); + expect(result).not.toBeNull(); + expect(result).toMatch(/^data:image\/svg\+xml;base64,/); + }); + + it("should produce different SVGs for different icons", async () => { + const user = await lucideIconToDataUri("user"); + const mail = await lucideIconToDataUri("mail"); + expect(user).not.toBeNull(); + expect(mail).not.toBeNull(); + expect(user).not.toEqual(mail); + }); + + it("should return null when module has no __iconNode", async () => { + const result = await lucideIconToDataUri("missing-icon-node"); + expect(result).toBeNull(); + }); + + it("should return null when dynamic import throws", async () => { + const result = await lucideIconToDataUri("throwing-icon"); + expect(result).toBeNull(); + }); + + it("should not include React key attributes in the SVG output", async () => { + const result = await lucideIconToDataUri("user"); + expect(result).not.toBeNull(); + + const base64 = result!.replace("data:image/svg+xml;base64,", ""); + const svg = atob(base64); + expect(svg).not.toContain("key="); + }); +}); + +describe("lucide reference helpers", () => { + it("LUCIDE_PREFIX is 'lucide:'", () => { + expect(LUCIDE_PREFIX).toBe("lucide:"); + }); + + it("isLucideIconRef recognizes lucide refs", () => { + expect(isLucideIconRef("lucide:plane")).toBe(true); + expect(isLucideIconRef("lucide:log-in")).toBe(true); + }); + + it("isLucideIconRef rejects other values", () => { + expect(isLucideIconRef(undefined)).toBe(false); + expect(isLucideIconRef("")).toBe(false); + expect(isLucideIconRef("data:image/svg+xml;base64,XXX")).toBe(false); + expect(isLucideIconRef("https://example.com/icon.svg")).toBe(false); + }); + + it("getLucideName extracts the name from a lucide ref", () => { + expect(getLucideName("lucide:plane")).toBe("plane"); + expect(getLucideName("lucide:log-in")).toBe("log-in"); + }); + + it("getLucideName returns null for non-lucide values", () => { + expect(getLucideName(undefined)).toBeNull(); + expect(getLucideName("")).toBeNull(); + expect(getLucideName("data:image/svg+xml;base64,XXX")).toBeNull(); + expect(getLucideName("https://example.com/icon.svg")).toBeNull(); + }); + + it("toLucideIconRef builds a lucide ref", () => { + expect(toLucideIconRef("plane")).toBe("lucide:plane"); + expect(toLucideIconRef("log-in")).toBe("lucide:log-in"); + }); +}); + +describe("getLucideSvgString", () => { + it("returns raw SVG markup for a valid icon", async () => { + const svg = await getLucideSvgString("user"); + expect(svg).not.toBeNull(); + expect(svg).toContain(""); + expect(svg).toContain("currentColor"); + }); + + it("returns null for an unknown icon", async () => { + const svg = await getLucideSvgString("not-a-real-icon-name"); + expect(svg).toBeNull(); + }); +}); + +describe("resolveIconUrl", () => { + it("resolves lucide: to a data URI", async () => { + const result = await resolveIconUrl("lucide:user"); + expect(result).toMatch(/^data:image\/svg\+xml;base64,/); + }); + + it("returns null for unknown lucide name", async () => { + const result = await resolveIconUrl("lucide:not-a-real-icon-name"); + expect(result).toBeNull(); + }); + + it("passes through data: URIs unchanged", async () => { + const dataUri = "data:image/svg+xml;base64,PHN2Zy8+"; + const result = await resolveIconUrl(dataUri); + expect(result).toBe(dataUri); + }); + + it("passes through plain URLs unchanged", async () => { + const url = "https://example.com/icon.svg"; + const result = await resolveIconUrl(url); + expect(result).toBe(url); + }); + + it("returns null for undefined", async () => { + const result = await resolveIconUrl(undefined); + expect(result).toBeNull(); + }); + + it("returns null for empty string", async () => { + const result = await resolveIconUrl(""); + expect(result).toBeNull(); + }); +}); diff --git a/packages/graph-explorer/src/utils/lucideIconUrl.ts b/packages/graph-explorer/src/utils/lucideIconUrl.ts new file mode 100644 index 000000000..387aac24a --- /dev/null +++ b/packages/graph-explorer/src/utils/lucideIconUrl.ts @@ -0,0 +1,141 @@ +import dynamicIconImports from "lucide-react/dynamicIconImports"; + +type IconNodeChild = [string, Record]; + +interface LucideIconModule { + __iconNode?: IconNodeChild[]; + default?: unknown; +} + +/** + * Storage prefix for Lucide icon references in `iconUrl` fields. + * A stored value like `lucide:plane` preserves the symbolic icon name so + * the picker can highlight the current selection and config/export files + * can carry human-readable icon references. + */ +export const LUCIDE_PREFIX = "lucide:"; + +/** True if `iconUrl` is a stored Lucide reference. */ +export function isLucideIconRef(iconUrl: string | undefined): boolean { + return !!iconUrl && iconUrl.startsWith(LUCIDE_PREFIX); +} + +/** Extract the icon name from a `lucide:` reference, or null. */ +export function getLucideName(iconUrl: string | undefined): string | null { + if (!isLucideIconRef(iconUrl)) return null; + return iconUrl!.slice(LUCIDE_PREFIX.length); +} + +/** Build a `lucide:` reference for storage. */ +export function toLucideIconRef(iconName: string): string { + return `${LUCIDE_PREFIX}${iconName}`; +} + +/** + * Resolves a stored `iconUrl` to a value suitable for `` / `` src. + * - `lucide:` → base64 SVG data URI (resolved + cached) + * - `data:...` → passthrough + * - any other string → passthrough (treated as URL) + * - `undefined` / `null` → null + */ +export async function resolveIconUrl( + iconUrl: string | undefined, +): Promise { + if (!iconUrl) return null; + const name = getLucideName(iconUrl); + if (name) return await lucideIconToDataUri(name); + return iconUrl; +} + +/** + * Returns the raw SVG markup for a Lucide icon by name, or null if unknown. + * Used by render pipelines (e.g., Cytoscape) that need the SVG text rather + * than a data URI. + */ +export async function getLucideSvgString( + iconName: string, +): Promise { + const iconNode = await getLucideIconNode(iconName); + if (!iconNode) return null; + return buildSvgString(iconNode); +} + +/** + * Converts a Lucide icon name to a base64-encoded SVG data URI. + * + * Icon names use kebab-case (e.g., "user", "log-in", "landmark"). + * See https://lucide.dev/icons for available icon names. + * + * @param iconName The kebab-case icon name from lucide. + * @returns A data URI string for the SVG icon, or null if the icon name is not found. + */ +export async function lucideIconToDataUri( + iconName: string, +): Promise { + const svgString = await getLucideSvgString(iconName); + if (!svgString) return null; + return `data:image/svg+xml;base64,${btoa(svgString)}`; +} + +const iconCache = new Map(); + +async function getLucideIconNode( + iconName: string, +): Promise { + if (iconCache.has(iconName)) { + return iconCache.get(iconName) ?? null; + } + + try { + // Use lucide-react's dynamicIconImports which provides Vite-compatible + // lazy loaders for each icon. Each module exports __iconNode as a named + // export containing SVG element data as [elementTag, attributes][] tuples. + const importFn = + dynamicIconImports[iconName as keyof typeof dynamicIconImports]; + if (!importFn) { + iconCache.set(iconName, null); + return null; + } + + const mod = (await importFn()) as LucideIconModule; + + if (!mod.__iconNode) { + iconCache.set(iconName, null); + return null; + } + + iconCache.set(iconName, mod.__iconNode); + return mod.__iconNode; + } catch { + iconCache.set(iconName, null); + return null; + } +} + +function escapeXmlAttr(value: string): string { + return value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} + +function buildSvgString(nodes: IconNodeChild[]): string { + const children = nodes + .map(([tag, attrs]) => { + const attrStr = Object.entries(attrs) + .filter(([key]) => key !== "key") + .map(([key, value]) => `${key}="${escapeXmlAttr(value)}"`) + .join(" "); + return `<${tag} ${attrStr} />`; + }) + .join(""); + + return ( + `` + + `${children}` + ); +} diff --git a/packages/graph-explorer/src/utils/useResolvedIconUrl.test.tsx b/packages/graph-explorer/src/utils/useResolvedIconUrl.test.tsx new file mode 100644 index 000000000..b5a094795 --- /dev/null +++ b/packages/graph-explorer/src/utils/useResolvedIconUrl.test.tsx @@ -0,0 +1,47 @@ +// @vitest-environment happy-dom +import { waitFor } from "@testing-library/react"; + +import { renderHookWithState } from "@/utils/testing"; + +import { useResolvedIconUrl } from "./useResolvedIconUrl"; + +describe("useResolvedIconUrl", () => { + it("returns a plain URL synchronously (no async wait)", () => { + const url = "https://example.com/icon.svg"; + const { result } = renderHookWithState(() => useResolvedIconUrl(url)); + expect(result.current).toBe(url); + }); + + it("returns a data: URI synchronously (no async wait)", () => { + const dataUri = "data:image/svg+xml;base64,PHN2Zy8+"; + const { result } = renderHookWithState(() => useResolvedIconUrl(dataUri)); + expect(result.current).toBe(dataUri); + }); + + it("resolves lucide: to a data URI asynchronously", async () => { + const { result } = renderHookWithState(() => + useResolvedIconUrl("lucide:user"), + ); + + expect(result.current).toBe(""); + + await waitFor(() => { + expect(result.current).toMatch(/^data:image\/svg\+xml;base64,/); + }); + }); + + it("returns empty string for unknown lucide name", async () => { + const { result } = renderHookWithState(() => + useResolvedIconUrl("lucide:not-a-real-icon-name-xyz"), + ); + + await waitFor(() => { + expect(result.current).toBe(""); + }); + }); + + it("returns empty string for an empty iconUrl", () => { + const { result } = renderHookWithState(() => useResolvedIconUrl("")); + expect(result.current).toBe(""); + }); +}); diff --git a/packages/graph-explorer/src/utils/useResolvedIconUrl.ts b/packages/graph-explorer/src/utils/useResolvedIconUrl.ts new file mode 100644 index 000000000..b7ba6d3bb --- /dev/null +++ b/packages/graph-explorer/src/utils/useResolvedIconUrl.ts @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; + +import { isLucideIconRef, resolveIconUrl } from "./lucideIconUrl"; + +/** + * Resolves a stored `iconUrl` to a value suitable for `` / `` src. + * + * - `lucide:` refs are resolved asynchronously to a base64 SVG data URI + * and cached per session via React Query (`staleTime: Infinity`). + * - `data:` URIs and plain URLs pass through synchronously via `initialData`, + * so consumers can render them immediately on the first paint. + * - Returns the empty string while a lucide ref is loading on cold-cache. + */ +export function useResolvedIconUrl(iconUrl: string): string { + const { data } = useQuery({ + queryKey: ["resolved-icon", iconUrl], + queryFn: () => resolveIconUrl(iconUrl), + staleTime: Infinity, + initialData: isLucideIconRef(iconUrl) ? undefined : iconUrl, + }); + return data ?? ""; +} diff --git a/vitest.config.ts b/vitest.config.ts index 34c3e30c1..b291732a8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ thresholds: { autoUpdate: (newThreshold: number) => Math.floor(newThreshold), statements: 64, - branches: 44, + branches: 45, functions: 58, lines: 72, },