// @ts-nocheck — vendored, see PROVENANCE.md "use client"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Palette } from "../lib/tokens"; import { t, useLang } from "../lib/i18n"; type IconMeta = { n: string; p: number; t: string }; const FONT = '24px "Material Symbols Rounded"'; /** module-level so validation survives re-mounts of the panel */ let cache: IconMeta[] | null = null; const glyphOk = new Map(); /** Candidates measured per pass. A real Material Symbols glyph is exactly 1em * wide; a name with no glyph falls back to text and measures wider. Icon names * can be as short as "tv"/"4k", so we compare against a known-good reference * glyph rather than a width threshold. */ const BATCH = 400; const SHOWN = 240; export function IconPicker({ value, onChange, palette, }: { value: string | null; onChange: (icon: string | null) => void; palette: Palette; }) { const lang = useLang(); const [icons, setIcons] = useState(cache); const [q, setQ] = useState(""); const [fontReady, setFontReady] = useState(false); /* bumped after each measuring pass; the visible list depends on it because * glyphOk is a plain map, not React state */ const [tick, bump] = useState(0); const refEl = useRef(null); const probeEls = useRef>(new Map()); useEffect(() => { if (cache) return; fetch(`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/material-symbols.json`) .then((r) => r.json()) .then((d: IconMeta[]) => { cache = d; setIcons(d); }) .catch(() => setIcons([])); }, []); useEffect(() => { let alive = true; const done = () => alive && setFontReady(true); if (document.fonts.check(FONT)) { done(); return; } document.fonts.load(FONT, "search").then(done, done); return () => { alive = false; }; }, []); const candidates = useMemo(() => { if (!icons) return []; const s = q.trim().toLowerCase().replace(/\s+/g, "_"); if (!s) return icons.slice(0, BATCH); const raw = q.trim().toLowerCase(); const starts: IconMeta[] = []; const rest: IconMeta[] = []; for (const i of icons) { if (i.n.startsWith(s)) starts.push(i); else if (i.n.includes(s) || i.t.includes(raw)) rest.push(i); if (starts.length + rest.length >= BATCH * 2) break; } return [...starts, ...rest].slice(0, BATCH); }, [icons, q]); const unknown = useMemo(() => candidates.filter((c) => !glyphOk.has(c.n)), [candidates]); useLayoutEffect(() => { if (!fontReady || unknown.length === 0) return; const ref = refEl.current?.getBoundingClientRect().width ?? 0; if (ref <= 0) return; let learned = false; for (const c of unknown) { const el = probeEls.current.get(c.n); if (!el) continue; const w = el.getBoundingClientRect().width; glyphOk.set(c.n, Math.abs(w - ref) < 0.75); learned = true; } if (learned) bump((v) => v + 1); }, [fontReady, unknown]); const visible = useMemo( () => candidates.filter((c) => glyphOk.get(c.n) === true).slice(0, SHOWN), // eslint-disable-next-line react-hooks/exhaustive-deps [candidates, fontReady, tick], ); const loading = !icons || !fontReady; return (
{/* hidden probes — measured before paint, so nothing broken ever renders */}
search {unknown.map((c) => ( { if (el) probeEls.current.set(c.n, el); else probeEls.current.delete(c.n); }} className="msr" style={{ fontSize: 24 }} > {c.n} ))}
search setQ(e.target.value)} placeholder={icons ? t("searchIcons", lang) : "…"} style={{ width: "100%", height: 40, paddingLeft: 40, paddingRight: 12, borderRadius: 20, border: "none", background: palette.surfaceContainerHigh, color: palette.onSurface, fontSize: 14, outline: "none", }} />
{visible.map((i) => ( ))} {!loading && visible.length === 0 && (
search_off
)} {loading && (
hourglass_top
)}
); }