diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 70d0d38669..dea3168697 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -92,6 +92,7 @@ import { } from "@/lib/sql/sqlNavigation"; import { buildHoverTableSql, ddlForHoverPreview, hoverTableMatchesScope, normalizeAlignedSqlWhitespace, quoteIdentifier, quoteQualifiedName, reformatHoverDdl, scopeHoverTables, type HoverTableScope } from "@/lib/editor/hoverTableSql"; import { constrainSqlHoverLayout } from "@/lib/editor/sqlHoverLayout"; +import { createHoverSearch, type HoverSearchController } from "@/lib/editor/sqlHoverSearch"; import { lineColumnToOffset, sqlErrorDecorationRange as resolveSqlErrorDecorationRange } from "@/lib/sql/sqlDiagnostics"; import { analyzeMysqlRoutineSyntax, supportsMysqlRoutineSyntaxDiagnostics } from "@/lib/sql/mysqlRoutineSyntaxDiagnostics"; import { buildOracleSyntaxDiagnostics } from "@/lib/sql/oracleSyntaxDiagnostics"; @@ -2655,6 +2656,7 @@ function createHoverDom(title: string, detail: string, sqlContent?: string, rows let layoutController: ReturnType | null = null; let handleCopy: ((event: ClipboardEvent) => void) | null = null; + let searchController: HoverSearchController | null = null; if (sqlContent) { heading.className = "flex items-center justify-between gap-3 font-medium"; @@ -2699,7 +2701,18 @@ function createHoverDom(title: string, detail: string, sqlContent?: string, rows sqlContainer.textContent = sqlContent; } + // 纯前端搜索:在已生成的 DDL 内容上做大小写不敏感匹配并高亮,不重新请求元数据/DDL。 + // originalHtml 必须在挂到文档前、内容渲染后捕获,作为每次搜索的还原基线。 + searchController = createHoverSearch({ + target: sqlContainer, + originalHtml: sqlContainer.innerHTML, + placeholder: t("grid.hoverSearchPlaceholder"), + noResultLabel: t("grid.hoverSearchNoResult"), + }); + dom.appendChild(searchController.element); + dom.appendChild(sqlContainer); + dom.appendChild(searchController.status); // 返回 mount/destroy 给 CodeMirror TooltipView 生命周期钩子, // 避免 MutationObserver 监听 body 全子树来兜底清理。 layoutController = constrainSqlHoverLayout(dom, sqlContainer); @@ -2733,16 +2746,17 @@ function createHoverDom(title: string, detail: string, sqlContent?: string, rows return { dom, mount: - layoutController || handleCopy + layoutController || handleCopy || searchController ? () => { layoutController?.mount(); if (handleCopy) document.addEventListener("copy", handleCopy); } : undefined, destroy: - layoutController || handleCopy + layoutController || handleCopy || searchController ? () => { layoutController?.destroy(); + searchController?.destroy(); if (handleCopy) document.removeEventListener("copy", handleCopy); } : undefined, diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 27a2b05a2d..a96bf85631 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1632,6 +1632,8 @@ export default { mongoJsonPreviewEmpty: "Select a MongoDB document to preview.", copyJson: "Copy JSON", copyDdl: "Copy DDL", + hoverSearchPlaceholder: "Search columns…", + hoverSearchNoResult: "No matching columns", copyCell: "Copy Cell", copyRow: "Copy Entire Row (JSON Object)", copyColumnJson: "Copy Column (JSON)", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 66d83a4034..17bc550ecb 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1523,6 +1523,8 @@ export default withEnglishFallback({ mongoJsonPreviewEmpty: "Selecciona un documento de MongoDB para obtener una vista previa.", copyJson: "Copiar JSON", copyDdl: "Copiar DDL", + hoverSearchPlaceholder: "Buscar columnas…", + hoverSearchNoResult: "No hay columnas coincidentes", copyCell: "Copiar celda", copyRow: "Copiar fila completa (objeto JSON)", copyColumnJson: "Copiar columna (JSON)", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index a91d5aebae..458547709d 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1521,6 +1521,8 @@ export default withEnglishFallback({ mongoJsonPreviewEmpty: "Seleziona un documento MongoDB da visualizzare.", copyJson: "Copia JSON", copyDdl: "Copia DDL", + hoverSearchPlaceholder: "Cerca colonne…", + hoverSearchNoResult: "Nessuna colonna corrispondente", copyCell: "Copia Cella", copyRow: "Copia intera riga (oggetto JSON)", copyColumnJson: "Copia Colonna (JSON)", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index a59f3d9b3a..e15ef54da5 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1535,6 +1535,8 @@ export default withEnglishFallback({ mongoJsonPreviewEmpty: "プレビューする MongoDB ドキュメントを選択してください。", copyJson: "JSON をコピー", copyDdl: "DDLをコピー", + hoverSearchPlaceholder: "列を検索…", + hoverSearchNoResult: "一致する列はありません", copyCell: "セルをコピー", copyRow: "行全体をコピー(JSON オブジェクト)", copyColumnJson: "列をコピー (JSON)", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 35551d4630..2cda39a38e 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -1513,6 +1513,8 @@ export default withEnglishFallback({ mongoJsonPreviewEmpty: "미리보려면 MongoDB 문서를 선택하세요.", copyJson: "JSON 복사", copyDdl: "DDL 복사", + hoverSearchPlaceholder: "열 검색…", + hoverSearchNoResult: "일치하는 열 없음", copyCell: "셀 복사", copyRow: "전체 행 복사(JSON 객체)", copyColumnJson: "컬럼 복사 (JSON)", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 7cbe103f17..e1360dc495 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1523,6 +1523,8 @@ export default withEnglishFallback({ mongoJsonPreviewEmpty: "Selecione um documento MongoDB para visualizar.", copyJson: "Copiar JSON", copyDdl: "Copiar DDL", + hoverSearchPlaceholder: "Pesquisar colunas…", + hoverSearchNoResult: "Nenhuma coluna correspondente", copyCell: "Copiar Célula", copyRow: "Copiar linha inteira (objeto JSON)", copyColumnJson: "Copiar Coluna (JSON)", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 8e796853ec..c473a3e0ef 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1555,6 +1555,8 @@ export default withEnglishFallback({ mongoJsonPreviewEmpty: "选择一条 MongoDB 文档以预览。", copyJson: "复制 JSON", copyDdl: "复制 DDL", + hoverSearchPlaceholder: "搜索列…", + hoverSearchNoResult: "没有匹配的列", copyCell: "复制单元格", copyRow: "复制整行(JSON 对象)", copyColumnJson: "复制列 (JSON)", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index a802ed72f8..989006f7c2 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1521,6 +1521,8 @@ export default withEnglishFallback({ mongoJsonPreviewEmpty: "選擇一筆 MongoDB 文件以預覽。", copyJson: "複製 JSON", copyDdl: "複製 DDL", + hoverSearchPlaceholder: "搜尋欄位…", + hoverSearchNoResult: "沒有符合的欄位", copyCell: "複製儲存格", copyRow: "複製整筆(JSON 物件)", copyColumnJson: "複製欄 (JSON)", diff --git a/apps/desktop/src/lib/editor/__tests__/sqlHoverSearch.spec.ts b/apps/desktop/src/lib/editor/__tests__/sqlHoverSearch.spec.ts new file mode 100644 index 0000000000..f80b3efa5a --- /dev/null +++ b/apps/desktop/src/lib/editor/__tests__/sqlHoverSearch.spec.ts @@ -0,0 +1,215 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from "vitest"; +import { applyHoverSearchHighlights, clearHoverSearchHighlights, createHoverSearch, findHoverSearchMatches } from "@/lib/editor/sqlHoverSearch"; + +const DDL = ["create table `orders` (", " `id` bigint not null,", " `customer_order_status` varchar(32) null,", " `USER_ID` bigint null,", " `amount` decimal(10,2) null", ");"].join("\n"); + +describe("findHoverSearchMatches", () => { + it("returns no matches for an empty or whitespace query", () => { + expect(findHoverSearchMatches(DDL, "")).toEqual([]); + expect(findHoverSearchMatches(DDL, " ")).toEqual([]); + }); + + it("matches a full column name", () => { + const matches = findHoverSearchMatches(DDL, "customer_order_status"); + expect(matches).toHaveLength(1); + expect(DDL.slice(matches[0].start, matches[0].end)).toBe("customer_order_status"); + }); + + it("matches a partial column name", () => { + const matches = findHoverSearchMatches(DDL, "order"); + // Appears in table name `orders` and in `customer_order_status`. + expect(matches.length).toBeGreaterThanOrEqual(2); + }); + + it("is case-insensitive", () => { + expect(findHoverSearchMatches(DDL, "user_id")).toHaveLength(1); + expect(findHoverSearchMatches(DDL, "USER_ID")).toHaveLength(1); + expect(findHoverSearchMatches(DDL, "User_Id")).toHaveLength(1); + }); + + it("returns an empty array when nothing matches", () => { + expect(findHoverSearchMatches(DDL, "no_such_column")).toEqual([]); + }); + + it("treats regex metacharacters literally without throwing", () => { + // None of these should be interpreted as a pattern. + expect(() => findHoverSearchMatches(DDL, "(")).not.toThrow(); + expect(() => findHoverSearchMatches(DDL, "[a-z")).not.toThrow(); + expect(() => findHoverSearchMatches(DDL, "decimal(10,2)")).not.toThrow(); + expect(findHoverSearchMatches(DDL, "(10,2)")).toHaveLength(1); + expect(findHoverSearchMatches(DDL, ".*")).toEqual([]); + }); + + it("finds non-overlapping repeated occurrences", () => { + const matches = findHoverSearchMatches("aaaa", "aa"); + expect(matches).toEqual([ + { start: 0, end: 2 }, + { start: 2, end: 4 }, + ]); + }); +}); + +describe("applyHoverSearchHighlights", () => { + it("wraps matches in without altering the visible text", () => { + const container = document.createElement("div"); + container.textContent = DDL; + const matches = findHoverSearchMatches(DDL, "order"); + const marks = applyHoverSearchHighlights(container, matches); + + expect(marks.length).toBe(matches.length); + expect(container.querySelectorAll("mark").length).toBe(matches.length); + expect(container.textContent).toBe(DDL); + // First match is tagged active for scroll-into-view. + expect(marks[0].getAttribute("data-sql-hover-search-active")).toBe("true"); + }); + + it("preserves surrounding highlight spans (only text nodes are split)", () => { + const container = document.createElement("div"); + const span = document.createElement("span"); + span.style.color = "red"; + span.textContent = "customer_order_status"; + container.append(document.createTextNode(" "), span, document.createTextNode(" varchar")); + + const content = container.textContent!; + const matches = findHoverSearchMatches(content, "order"); + applyHoverSearchHighlights(container, matches); + + // The colored span is still present and now contains the mark. + const preservedSpan = container.querySelector("span"); + expect(preservedSpan?.style.color).toBe("red"); + expect(preservedSpan?.querySelector("mark")).not.toBeNull(); + expect(container.textContent).toBe(content); + }); + + it("clears highlights and restores the original text nodes", () => { + const container = document.createElement("div"); + container.textContent = DDL; + applyHoverSearchHighlights(container, findHoverSearchMatches(DDL, "id")); + expect(container.querySelector("mark")).not.toBeNull(); + + clearHoverSearchHighlights(container); + expect(container.querySelector("mark")).toBeNull(); + expect(container.textContent).toBe(DDL); + }); +}); + +describe("createHoverSearch", () => { + function setup() { + const target = document.createElement("div"); + target.textContent = DDL; + const controller = createHoverSearch({ + target, + originalHtml: target.innerHTML, + placeholder: "Search columns…", + noResultLabel: "No matching columns", + }); + const input = controller.element.querySelector('[data-sql-hover-search-input="true"]')!; + return { target, controller, input }; + } + + it("highlights correctly when line breaks render as
elements", () => { + const target = document.createElement("div"); + // Mirror the syntax highlighter's inline output: lines separated by
, + // so textContent contains no newline characters. + const lines = DDL.split("\n"); + lines.forEach((line, index) => { + if (index > 0) target.appendChild(document.createElement("br")); + if (line) target.appendChild(document.createTextNode(line)); + }); + const controller = createHoverSearch({ + target, + originalHtml: target.innerHTML, + placeholder: "Search columns…", + noResultLabel: "No matching columns", + }); + const input = controller.element.querySelector('[data-sql-hover-search-input="true"]')!; + + // `customer_order_status` sits on the third line — two `
`s in, exactly + // where newline-domain offsets used to drift into the wrong text. + input.value = "customer_order_status"; + input.dispatchEvent(new Event("input")); + + const marks = target.querySelectorAll("mark"); + expect(marks.length).toBeGreaterThan(0); + for (const mark of marks) { + expect(mark.textContent?.toLowerCase()).toContain(input.value.toLowerCase()); + } + expect(target.textContent?.toLowerCase()).toContain(input.value.toLowerCase()); + }); + + it("creates a search input and a hidden no-result status", () => { + const { controller, input } = setup(); + expect(input).not.toBeNull(); + expect(input.placeholder).toBe("Search columns…"); + expect(controller.status.hidden).toBe(true); + expect(controller.status.textContent).toBe("No matching columns"); + }); + + it("highlights matches as the query changes", () => { + const { target, input } = setup(); + input.value = "customer_order_status"; + input.dispatchEvent(new Event("input")); + expect(target.querySelectorAll("mark").length).toBe(1); + }); + + it("shows the no-result status and no marks when nothing matches", () => { + const { target, controller, input } = setup(); + input.value = "zzz_missing"; + input.dispatchEvent(new Event("input")); + expect(controller.status.hidden).toBe(false); + expect(target.querySelector("mark")).toBeNull(); + }); + + it("restores the full content when the query is cleared", () => { + const { target, controller, input } = setup(); + input.value = "id"; + input.dispatchEvent(new Event("input")); + expect(target.querySelector("mark")).not.toBeNull(); + + input.value = ""; + input.dispatchEvent(new Event("input")); + expect(target.querySelector("mark")).toBeNull(); + expect(controller.status.hidden).toBe(true); + expect(target.textContent).toBe(DDL); + }); + + it("stops keydown propagation so the editor keymap never sees typing", () => { + const { input } = setup(); + let leaked = false; + document.addEventListener("keydown", () => (leaked = true)); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + expect(leaked).toBe(false); + }); + + it("clears the query on Escape but keeps propagation stopped (tooltip stays open)", () => { + const { target, input } = setup(); + input.value = "id"; + input.dispatchEvent(new Event("input")); + let leaked = false; + document.addEventListener("keydown", () => (leaked = true)); + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + expect(input.value).toBe(""); + expect(target.querySelector("mark")).toBeNull(); + expect(leaked).toBe(false); + }); + + it("keeps pointerdown from bubbling to the editor (click focuses the input)", () => { + const { input } = setup(); + let leaked = false; + document.addEventListener("pointerdown", () => (leaked = true)); + input.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); + expect(leaked).toBe(false); + }); + + it("removes its listeners on destroy", () => { + const { target, controller, input } = setup(); + controller.destroy(); + input.value = "id"; + input.dispatchEvent(new Event("input")); + // No re-render after destroy. + expect(target.querySelector("mark")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/lib/editor/sqlHoverSearch.ts b/apps/desktop/src/lib/editor/sqlHoverSearch.ts new file mode 100644 index 0000000000..590ad6a043 --- /dev/null +++ b/apps/desktop/src/lib/editor/sqlHoverSearch.ts @@ -0,0 +1,218 @@ +/** + * Client-side search for the SQL table-structure hover tooltip. + * + * The hover already renders the full table DDL (syntax-highlighted). When a + * table has many columns, finding one by eye is slow, so this module adds a + * compact search box that highlights matches inside the *already fetched* DDL + * text — no extra metadata or DDL request is made. Matching runs over the + * container's `textContent` — the same offset domain the highlight walker + * accumulates over its text nodes — never over the highlighted HTML, and uses + * substring search (not a `RegExp` built from user input) so special + * characters can never break matching. Keeping corpus and walker on the same + * source matters because the syntax highlighter renders line breaks as `
` + * elements: its `textContent` has no newline characters even though the raw + * DDL string does. + */ + +const MATCH_ATTRIBUTE = "data-sql-hover-search-match"; +const ACTIVE_ATTRIBUTE = "data-sql-hover-search-active"; + +export interface HoverSearchMatch { + /** Offset of the first matched character within the searched text. */ + start: number; + /** Offset just past the last matched character within the searched text. */ + end: number; +} + +/** + * Find every case-insensitive, non-overlapping occurrence of `query` inside the + * searched text. A blank/whitespace-only query yields no matches (the caller + * treats this as "restore full content"). Uses `indexOf`, so arbitrary user + * input — including regex metacharacters like `()[]$.*` — is matched literally. + */ +export function findHoverSearchMatches(text: string, query: string): HoverSearchMatch[] { + const needle = query.trim().toLowerCase(); + if (!needle) return []; + const haystack = text.toLowerCase(); + const matches: HoverSearchMatch[] = []; + let from = 0; + for (;;) { + const index = haystack.indexOf(needle, from); + if (index === -1) break; + matches.push({ start: index, end: index + needle.length }); + from = index + needle.length; + } + return matches; +} + +/** + * Wrap each matched range in a `` by walking the container's text nodes, + * so the surrounding syntax-highlight spans are preserved untouched. A match + * that straddles two adjacent text nodes is highlighted per node segment. + * Match offsets are relative to the concatenated text-node content (the + * container's `textContent`); call {@link clearHoverSearchHighlights} (or + * restore the original HTML) before re-applying. + * + * Returns the created `` elements in document order; the first is tagged + * as the active match so callers can scroll it into view. + */ +export function applyHoverSearchHighlights(container: HTMLElement, matches: HoverSearchMatch[]): HTMLElement[] { + if (matches.length === 0) return []; + + // Snapshot text nodes with their global offsets before mutating the DOM. + const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT); + const textNodes: Array<{ node: Text; start: number; end: number }> = []; + let offset = 0; + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const text = node as Text; + const length = text.data.length; + textNodes.push({ node: text, start: offset, end: offset + length }); + offset += length; + } + + const marks: HTMLElement[] = []; + for (const { node, start, end } of textNodes) { + // Ranges (clamped to this node) that overlap the node, in node-local coords. + const segments = matches.filter((match) => match.start < end && match.end > start).map((match) => ({ from: Math.max(match.start, start) - start, to: Math.min(match.end, end) - start })); + if (segments.length === 0) continue; + + const data = node.data; + const fragment = document.createDocumentFragment(); + let cursor = 0; + for (const { from, to } of segments) { + if (from > cursor) fragment.appendChild(document.createTextNode(data.slice(cursor, from))); + const mark = document.createElement("mark"); + mark.setAttribute(MATCH_ATTRIBUTE, "true"); + mark.className = "rounded-[2px] bg-yellow-300/70 px-px text-inherit dark:bg-yellow-500/40"; + mark.textContent = data.slice(from, to); + fragment.appendChild(mark); + marks.push(mark); + cursor = to; + } + if (cursor < data.length) fragment.appendChild(document.createTextNode(data.slice(cursor))); + node.replaceWith(fragment); + } + + marks[0]?.setAttribute(ACTIVE_ATTRIBUTE, "true"); + return marks; +} + +/** Remove every highlight mark, merging its text back into the surrounding nodes. */ +export function clearHoverSearchHighlights(container: HTMLElement): void { + const marks = container.querySelectorAll(`[${MATCH_ATTRIBUTE}]`); + for (const mark of marks) mark.replaceWith(...mark.childNodes); + container.normalize(); +} + +export interface HoverSearchController { + /** Row element to insert above the scrollable DDL content. */ + element: HTMLElement; + /** No-result status element (inserted after the content by the caller). */ + status: HTMLElement; + /** Focus the search input. */ + focus(): void; + /** Remove listeners; safe to call once when the tooltip is destroyed. */ + destroy(): void; +} + +export interface HoverSearchOptions { + /** Scrollable element whose innerHTML holds the rendered DDL. */ + target: HTMLElement; + /** The target's pristine innerHTML, restored before every re-highlight. */ + originalHtml: string; + /** Input placeholder text. */ + placeholder: string; + /** Message shown when a non-empty query has no matches. */ + noResultLabel: string; +} + +/** + * Build the compact search box + no-result status for the hover tooltip and + * wire live filtering. Interaction is isolated from the CodeMirror editor: + * + * - `pointerdown` stops propagation (but keeps default) so clicking the input + * focuses it without the editor treating it as "hover ended". + * - `keydown`/`keyup` stop propagation so editor keymap shortcuts and IME + * Enter/composition never fire or dismiss the tooltip while typing. + */ +export function createHoverSearch(options: HoverSearchOptions): HoverSearchController { + const { target, originalHtml, placeholder, noResultLabel } = options; + + const element = document.createElement("div"); + element.dataset.sqlHoverSearch = "true"; + element.className = "mt-2 flex-none"; + + const input = document.createElement("input"); + input.type = "text"; + input.dataset.sqlHoverSearchInput = "true"; + input.placeholder = placeholder; + input.setAttribute("aria-label", placeholder); + input.spellcheck = false; + input.autocomplete = "off"; + input.className = "w-full rounded border border-border/60 bg-background px-2 py-1 text-[11px] leading-none text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"; + element.appendChild(input); + + const status = document.createElement("div"); + status.dataset.sqlHoverSearchEmpty = "true"; + status.className = "mt-1.5 text-[11px] text-muted-foreground"; + status.textContent = noResultLabel; + status.hidden = true; + + const runSearch = () => { + // Restore the pristine highlighted DDL, then re-apply match highlights. + target.innerHTML = originalHtml; + const query = input.value; + // Search the rendered text, not the raw DDL string: the highlighter emits + // `
` for line breaks, so `textContent` differs from the raw DDL by the + // dropped newline characters — and the walker's offsets live in the + // `textContent` domain. Using any other corpus shifts every match after + // the first line. + const matches = findHoverSearchMatches(target.textContent ?? "", query); + if (!query.trim()) { + status.hidden = true; + return; + } + if (matches.length === 0) { + status.hidden = false; + return; + } + status.hidden = true; + const marks = applyHoverSearchHighlights(target, matches); + marks[0]?.scrollIntoView({ block: "nearest" }); + }; + + const stopKeyboard = (event: KeyboardEvent) => { + // Keep editor keymap shortcuts and IME Enter/Escape from leaking out of the + // input; Escape clears the search but keeps the tooltip open. + event.stopPropagation(); + if (event.key === "Escape" && input.value) { + event.preventDefault(); + input.value = ""; + runSearch(); + } + }; + const stopPointer = (event: Event) => { + event.stopPropagation(); + }; + + input.addEventListener("input", runSearch); + input.addEventListener("keydown", stopKeyboard); + input.addEventListener("keyup", stopKeyboard); + input.addEventListener("pointerdown", stopPointer); + input.addEventListener("mousedown", stopPointer); + + return { + element, + status, + focus() { + input.focus(); + }, + destroy() { + input.removeEventListener("input", runSearch); + input.removeEventListener("keydown", stopKeyboard); + input.removeEventListener("keyup", stopKeyboard); + input.removeEventListener("pointerdown", stopPointer); + input.removeEventListener("mousedown", stopPointer); + }, + }; +}