97 lines
4.7 KiB
TypeScript
97 lines
4.7 KiB
TypeScript
/// <reference lib="es2022.intl" />
|
|||
|
|
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<HTMLSpanElement>(null);
|
||
|
|
const measureRef = useRef<HTMLSpanElement>(null);
|
||
|
|
const ellipsisRef = useRef<HTMLSpanElement>(null);
|
||
|
|
const [kept, setKept] = useState<number | null>(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
|
||
|
|
? <strong key={`${start}:${run.start}`} className="font-semibold text-violet-100/95">{value}</strong>
|
||
|
|
: <span key={`${start}:${run.start}`}>{value}</span>];
|
||
|
|
});
|
||
|
|
|
||
|
|
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 (
|
||
|
|
<span ref={containerRef} title={text} className="relative col-start-2 block min-w-0 overflow-hidden whitespace-nowrap text-xs leading-5 text-secondary-foreground/75">
|
||
|
|
<span className="sr-only">{text}</span>
|
||
|
|
<span aria-hidden="true">
|
||
|
|
{truncated ? <>{renderRange(0, boundaries[Math.ceil(kept / 2)])}…{renderRange(boundaries[count - Math.floor(kept / 2)], text.length)}</> : renderRange(0, text.length)}
|
||
|
|
</span>
|
||
|
|
<span aria-hidden="true" className="pointer-events-none invisible absolute left-0 top-0 whitespace-pre">
|
||
|
|
<span ref={measureRef}>{renderRange(0, text.length)}</span><span ref={ellipsisRef}>…</span>
|
||
|
|
</span>
|
||
|
|
</span>
|
||
|
|
);
|
||
|
|
}
|