/// import { useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; const words = /[\p{L}\p{N}\p{M}\p{Co}]+/gu; const fold = (text: string) => text.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase(); const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" }); export function SearchMatchLabel({ text: source, query }: { text: string; query: string }) { const text = source.replace(/\s+/gu, " ").trim(); const containerRef = useRef(null); const measureRef = useRef(null); const ellipsisRef = useRef(null); const [kept, setKept] = useState(null); const boundaries = useMemo(() => [0, ...Array.from(graphemes.segment(text), ({ index, segment }) => index + segment.length)], [text]); const runs = useMemo(() => { const terms = (query.match(words) ?? []).map(fold).filter(Boolean); const result: Array<{ start: number; end: number; match: boolean }> = []; let cursor = 0; for (const word of text.matchAll(words)) { if (word.index > cursor) result.push({ start: cursor, end: word.index, match: false }); cursor = word.index + word[0].length; result.push({ start: word.index, end: cursor, match: terms.some((term) => fold(word[0]).startsWith(term)) }); } if (cursor < text.length) result.push({ start: cursor, end: text.length, match: false }); return result; }, [text, query]); const renderRange = (start: number, end: number) => runs.flatMap((run) => { const value = text.slice(Math.max(start, run.start), Math.min(end, run.end)); if (run.end <= start || run.start >= end) return []; return [run.match ? {value} : {value}]; }); useLayoutEffect(() => { const container = containerRef.current; const measure = measureRef.current; const ellipsis = ellipsisRef.current; if (!container || !measure || !ellipsis) return; let disposed = false; const update = () => { if (disposed) return; const width = container.getBoundingClientRect().width; if (measure.getBoundingClientRect().width <= width) { setKept(null); return; } // Measure the styled DOM, including bold runs, instead of guessing a // character limit. Grapheme boundaries keep accents and emoji intact. const nodes = Array.from(measure.children, (child) => child.firstChild as Text); const range = document.createRange(); const measureRange = (start: number, end: number) => { if (start >= end) return 0; let offset = 0; let started = false; for (const node of nodes) { const next = offset + node.length; if (!started && start <= next) { range.setStart(node, start - offset); started = true; } if (end <= next) { range.setEnd(node, end - offset); break; } offset = next; } return range.getBoundingClientRect().width; }; const count = boundaries.length - 1; const ellipsisWidth = ellipsis.getBoundingClientRect().width; let low = 0; let high = count - 1; while (low < high) { const middle = Math.ceil((low + high) / 2); const prefix = boundaries[Math.ceil(middle / 2)]; const suffix = boundaries[count - Math.floor(middle / 2)]; if (measureRange(0, prefix) + ellipsisWidth + measureRange(suffix, text.length) <= width - 1) low = middle; else high = middle - 1; } setKept(low); }; update(); const observer = new ResizeObserver(update); observer.observe(container); void document.fonts.ready.then(update); document.fonts.addEventListener("loadingdone", update); return () => { disposed = true; observer.disconnect(); document.fonts.removeEventListener("loadingdone", update); }; }, [text, runs, boundaries]); const count = boundaries.length - 1; const truncated = kept !== null && kept < count; return ( {text} ); }