mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 03:59:03 +00:00
feat(editor): search table structure hover with aligned offsets
This commit is contained in:
@@ -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<typeof constrainSqlHoverLayout> | 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,
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -1535,6 +1535,8 @@ export default withEnglishFallback({
|
||||
mongoJsonPreviewEmpty: "プレビューする MongoDB ドキュメントを選択してください。",
|
||||
copyJson: "JSON をコピー",
|
||||
copyDdl: "DDLをコピー",
|
||||
hoverSearchPlaceholder: "列を検索…",
|
||||
hoverSearchNoResult: "一致する列はありません",
|
||||
copyCell: "セルをコピー",
|
||||
copyRow: "行全体をコピー(JSON オブジェクト)",
|
||||
copyColumnJson: "列をコピー (JSON)",
|
||||
|
||||
@@ -1513,6 +1513,8 @@ export default withEnglishFallback({
|
||||
mongoJsonPreviewEmpty: "미리보려면 MongoDB 문서를 선택하세요.",
|
||||
copyJson: "JSON 복사",
|
||||
copyDdl: "DDL 복사",
|
||||
hoverSearchPlaceholder: "열 검색…",
|
||||
hoverSearchNoResult: "일치하는 열 없음",
|
||||
copyCell: "셀 복사",
|
||||
copyRow: "전체 행 복사(JSON 객체)",
|
||||
copyColumnJson: "컬럼 복사 (JSON)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -1555,6 +1555,8 @@ export default withEnglishFallback({
|
||||
mongoJsonPreviewEmpty: "选择一条 MongoDB 文档以预览。",
|
||||
copyJson: "复制 JSON",
|
||||
copyDdl: "复制 DDL",
|
||||
hoverSearchPlaceholder: "搜索列…",
|
||||
hoverSearchNoResult: "没有匹配的列",
|
||||
copyCell: "复制单元格",
|
||||
copyRow: "复制整行(JSON 对象)",
|
||||
copyColumnJson: "复制列 (JSON)",
|
||||
|
||||
@@ -1521,6 +1521,8 @@ export default withEnglishFallback({
|
||||
mongoJsonPreviewEmpty: "選擇一筆 MongoDB 文件以預覽。",
|
||||
copyJson: "複製 JSON",
|
||||
copyDdl: "複製 DDL",
|
||||
hoverSearchPlaceholder: "搜尋欄位…",
|
||||
hoverSearchNoResult: "沒有符合的欄位",
|
||||
copyCell: "複製儲存格",
|
||||
copyRow: "複製整筆(JSON 物件)",
|
||||
copyColumnJson: "複製欄 (JSON)",
|
||||
|
||||
@@ -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 <mark> 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<HTMLInputElement>('[data-sql-hover-search-input="true"]')!;
|
||||
return { target, controller, input };
|
||||
}
|
||||
|
||||
it("highlights correctly when line breaks render as <br> elements", () => {
|
||||
const target = document.createElement("div");
|
||||
// Mirror the syntax highlighter's inline output: lines separated by <br>,
|
||||
// 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<HTMLInputElement>('[data-sql-hover-search-input="true"]')!;
|
||||
|
||||
// `customer_order_status` sits on the third line — two `<br>`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();
|
||||
});
|
||||
});
|
||||
@@ -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 `<br>`
|
||||
* 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 `<mark>` 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 `<mark>` 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
|
||||
// `<br>` 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user