///
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}
{truncated ? <>{renderRange(0, boundaries[Math.ceil(kept / 2)])}…{renderRange(boundaries[count - Math.floor(kept / 2)], text.length)}> : renderRange(0, text.length)}
{renderRange(0, text.length)}…
);
}