mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-12 19:06:06 +02:00
* feat(issues): add Cmd+F in-page find to issue detail Replace the stopgap "find-in-page is virtualized" toast with a real find bar (MUL-4126). Cmd/Ctrl+F opens a floating bar with keyword input, live match count, and prev/next navigation that scrolls to and highlights each match. - Opening find force-renders the comment timeline flat (reusing the existing highlightCommentId escape hatch) so off-screen comments become searchable — the root cause of the original complaint. - Matches are painted with the CSS Custom Highlight API (ranges only, no DOM mutation), so highlighting layers cleanly over React-rendered markdown and the contenteditable title/description editors. - Scroll-to-match drives container.scrollTop directly (never native scrollIntoView; #3929). Co-authored-by: multica-agent <github@multica.ai> * fix(issues): keep in-page find usable without CSS Custom Highlight API On browsers lacking the CSS Custom Highlight API, `!supported` was folded into the match-collection path, so Cmd/Ctrl+F opened the bar and swallowed native find but reported 0 matches and could not navigate — strictly worse than the native find it replaced (MUL-4126 review). Feature-guard only the paint calls now: match collection, count, active index, and scroll-to-match run regardless of support, while `CSS.highlights.set/delete` / `new Highlight` stay behind the guard. The MutationObserver re-derives ranges even when unsupported so fallback counting/navigation track live DOM churn. Adds a hook test that drives the degraded path (jsdom has no highlight API) and asserts counting + prev/next still work. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
117 lines
3.3 KiB
TypeScript
117 lines
3.3 KiB
TypeScript
"use client";
|
|
|
|
import type { KeyboardEvent } from "react";
|
|
import { ChevronDown, ChevronUp, Search, X } from "lucide-react";
|
|
import { Input } from "@multica/ui/components/ui/input";
|
|
import { Button } from "@multica/ui/components/ui/button";
|
|
import { cn } from "@multica/ui/lib/utils";
|
|
import { useT } from "../../i18n";
|
|
import type { UseInPageFindResult } from "../hooks/use-in-page-find";
|
|
|
|
// Floating find-in-page bar for the issue detail page. Presentational — all
|
|
// search state, highlighting, and scrolling live in `useInPageFind`. Rendered
|
|
// only while `find.open`, so the input's autoFocus fires on every open.
|
|
//
|
|
// `data-find-ignore` keeps the bar's own text out of the match walk.
|
|
export function FindBar({
|
|
find,
|
|
className,
|
|
}: {
|
|
find: UseInPageFindResult;
|
|
className?: string;
|
|
}) {
|
|
const { t } = useT("issues");
|
|
const {
|
|
query,
|
|
matchCount,
|
|
activeIndex,
|
|
setQuery,
|
|
closeFind,
|
|
goNext,
|
|
goPrev,
|
|
inputRef,
|
|
} = find;
|
|
|
|
const hasQuery = query.trim().length > 0;
|
|
const countLabel = !hasQuery
|
|
? ""
|
|
: matchCount === 0
|
|
? t(($) => $.detail.find.no_matches)
|
|
: t(($) => $.detail.find.count, {
|
|
current: activeIndex + 1,
|
|
total: matchCount,
|
|
});
|
|
const noMatches = matchCount === 0;
|
|
|
|
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
if (e.shiftKey) goPrev();
|
|
else goNext();
|
|
} else if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
closeFind();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
data-find-ignore
|
|
role="search"
|
|
className={cn(
|
|
"flex items-center gap-1 rounded-lg border bg-popover/95 p-1 pl-2 shadow-md backdrop-blur supports-[backdrop-filter]:bg-popover/80",
|
|
className,
|
|
)}
|
|
>
|
|
<Search className="size-3.5 shrink-0 text-muted-foreground" />
|
|
<Input
|
|
ref={inputRef}
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
autoFocus
|
|
placeholder={t(($) => $.detail.find.placeholder)}
|
|
aria-label={t(($) => $.detail.find.placeholder)}
|
|
className="h-7 w-44 border-0 bg-transparent px-1 shadow-none focus-visible:ring-0"
|
|
/>
|
|
<span className="min-w-[3.5rem] shrink-0 whitespace-nowrap text-right text-xs tabular-nums text-muted-foreground">
|
|
{countLabel}
|
|
</span>
|
|
<div className="flex items-center">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
disabled={noMatches}
|
|
onClick={goPrev}
|
|
aria-label={t(($) => $.detail.find.previous)}
|
|
title={t(($) => $.detail.find.previous)}
|
|
>
|
|
<ChevronUp />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
disabled={noMatches}
|
|
onClick={goNext}
|
|
aria-label={t(($) => $.detail.find.next)}
|
|
title={t(($) => $.detail.find.next)}
|
|
>
|
|
<ChevronDown />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
onClick={closeFind}
|
|
aria-label={t(($) => $.detail.find.close)}
|
|
title={t(($) => $.detail.find.close)}
|
|
>
|
|
<X />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|