Files
m3e-canvas/web/vendor/components/IconPicker.tsx
T
pastilhasandClaude Fable 5 240d63940d vendor m3e-canvas 5c83ac6, with the import rewrite scripted
upstream imports through a @/ alias, and this platform already binds
@/* to src/apps/officer-web and @/components/* to its own shared
components — so an unrewritten import resolves to the WRONG module
rather than failing. the rewrite is mechanical and lives in
scripts/vendor.sh so a re-vendor produces a readable diff.

lib/ai.ts and AiPanel are vendored despite being unwanted: deleting them
meant editing vendored code, and Canvas.tsx references them throughout.
a faithful copy carrying an unused module beats a doctored one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-05 12:00:17 +01:00

209 lines
6.4 KiB
TypeScript

"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<string, boolean>();
/** 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<IconMeta[] | null>(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<HTMLSpanElement>(null);
const probeEls = useRef<Map<string, HTMLElement>>(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 (
<div>
{/* hidden probes — measured before paint, so nothing broken ever renders */}
<div
aria-hidden
style={{ position: "fixed", left: -99999, top: 0, visibility: "hidden", pointerEvents: "none" }}
>
<span ref={refEl} className="msr" style={{ fontSize: 24 }}>
search
</span>
{unknown.map((c) => (
<span
key={c.n}
ref={(el) => {
if (el) probeEls.current.set(c.n, el);
else probeEls.current.delete(c.n);
}}
className="msr"
style={{ fontSize: 24 }}
>
{c.n}
</span>
))}
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center", marginBottom: 8 }}>
<div style={{ position: "relative", flex: 1, minWidth: 0 }}>
<span
className="msr"
style={{ position: "absolute", left: 12, top: 10, fontSize: 20, color: palette.outline }}
>
search
</span>
<input
value={q}
onChange={(e) => 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",
}}
/>
</div>
</div>
<div
className="no-scrollbar"
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(42px, 1fr))",
gap: 4,
height: 244,
overflowY: "auto",
overflowX: "hidden",
padding: 6,
borderRadius: 16,
background: palette.surfaceContainerLow,
alignContent: "start",
}}
>
{visible.map((i) => (
<button
key={i.n}
title={i.n}
onClick={() => onChange(i.n)}
style={{
aspectRatio: "1",
minWidth: 0,
display: "grid",
placeItems: "center",
borderRadius: 12,
border: "none",
background: value === i.n ? palette.primary : "transparent",
color: value === i.n ? palette.onPrimary : palette.onSurfaceVariant,
cursor: "pointer",
}}
>
<span className="msr" style={{ fontSize: 22 }}>
{i.n}
</span>
</button>
))}
{!loading && visible.length === 0 && (
<div style={{ gridColumn: "1 / -1", padding: 16, fontSize: 13, color: palette.outline }}>
<span className="msr" style={{ fontSize: 24 }}>search_off</span>
</div>
)}
{loading && (
<div style={{ gridColumn: "1 / -1", padding: 16, fontSize: 13, color: palette.outline }}>
<span className="msr" style={{ fontSize: 24 }}>hourglass_top</span>
</div>
)}
</div>
</div>
);
}