fix(editor): navigate SQL hover search matches

Closes #9442
This commit is contained in:
t8y2 committed 2026-09-17 21:02:41 +08:00
1 parent 7e96d88787
commit 9ded32c4c1
2 files changed
+278 -30

No files matched your search

+214 -10
View File
@@ -1,10 +1,15 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } 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");
afterEach(() => {
vi.restoreAllMocks();
document.body.replaceChildren();
});
describe("findHoverSearchMatches", () => {
it("returns no matches for an empty or whitespace query", () => {
expect(findHoverSearchMatches(DDL, "")).toEqual([]);
@@ -93,12 +98,25 @@ describe("applyHoverSearchHighlights", () => {
expect(container.querySelector("mark")).toBeNull();
expect(container.textContent).toBe(DDL);
});
it("groups fragments of one logical match across syntax-highlight spans", () => {
const container = document.createElement("div");
container.innerHTML = "<span>order</span><span>_id</span> + <span>order_id</span>";
const originalText = container.textContent!;
const marks = applyHoverSearchHighlights(container, findHoverSearchMatches(originalText, "order_id"));
expect(marks.map((mark) => mark.getAttribute("data-sql-hover-search-match-index"))).toEqual(["0", "0", "1"]);
expect(marks.map((mark) => mark.hasAttribute("data-sql-hover-search-active"))).toEqual([true, true, false]);
expect(container.querySelectorAll("span")).toHaveLength(3);
expect(container.textContent).toBe(originalText);
});
});
describe("createHoverSearch", () => {
function setup() {
function setup(html?: string) {
const target = document.createElement("div");
target.textContent = DDL;
if (html === undefined) target.textContent = DDL;
else target.innerHTML = html;
const controller = createHoverSearch({
target,
originalHtml: target.innerHTML,
@@ -106,7 +124,19 @@ describe("createHoverSearch", () => {
noResultLabel: "No matching columns",
});
const input = controller.element.querySelector<HTMLInputElement>('[data-sql-hover-search-input="true"]')!;
return { target, controller, input };
const count = controller.element.querySelector<HTMLElement>('[data-sql-hover-search-count="true"]')!;
return { target, controller, input, count };
}
function pressKey(input: HTMLInputElement, key: string, options: KeyboardEventInit = {}) {
const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...options });
input.dispatchEvent(event);
input.dispatchEvent(new KeyboardEvent("keyup", { key, bubbles: true, ...options }));
return event;
}
function activeMarks(target: HTMLElement) {
return [...target.querySelectorAll<HTMLElement>('[data-sql-hover-search-active="true"]')];
}
it("highlights correctly when line breaks render as <br> elements", () => {
@@ -176,19 +206,19 @@ describe("createHoverSearch", () => {
});
it("stops keydown propagation so the editor keymap never sees typing", () => {
const { input } = setup();
const { controller, input } = setup();
let leaked = false;
document.addEventListener("keydown", () => (leaked = true));
controller.element.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();
const { target, controller, input } = setup();
input.value = "id";
input.dispatchEvent(new Event("input"));
let leaked = false;
document.addEventListener("keydown", () => (leaked = true));
controller.element.addEventListener("keydown", () => (leaked = true));
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
expect(input.value).toBe("");
@@ -197,9 +227,9 @@ describe("createHoverSearch", () => {
});
it("keeps pointerdown from bubbling to the editor (click focuses the input)", () => {
const { input } = setup();
const { controller, input } = setup();
let leaked = false;
document.addEventListener("pointerdown", () => (leaked = true));
controller.element.addEventListener("pointerdown", () => (leaked = true));
input.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
expect(leaked).toBe(false);
});
@@ -212,4 +242,178 @@ describe("createHoverSearch", () => {
// No re-render after destroy.
expect(target.querySelector("mark")).toBeNull();
});
it("shows the current match count and cycles forward on Enter without rerendering", () => {
const { target, controller, input, count } = setup("insurance_class + insurance_class AS insurance_class");
const scroll = vi.spyOn(HTMLElement.prototype, "scrollIntoView");
document.body.append(controller.element, target);
input.focus();
input.value = "insurance_class";
input.dispatchEvent(new Event("input"));
const marks = [...target.querySelectorAll("mark")];
expect(count.hidden).toBe(false);
expect(count.textContent).toBe("1 / 3");
expect(count.getAttribute("role")).toBe("status");
expect(count.getAttribute("aria-live")).toBe("polite");
expect(activeMarks(target)).toEqual([marks[0]]);
expect(scroll).toHaveBeenCalledTimes(1);
for (const index of [1, 2, 0]) {
expect(pressKey(input, "Enter").defaultPrevented).toBe(true);
expect(count.textContent).toBe(`${index + 1} / 3`);
expect(activeMarks(target)).toEqual([marks[index]]);
expect(scroll.mock.instances.at(-1)).toBe(marks[index]);
}
expect(scroll).toHaveBeenCalledTimes(4);
expect(target.querySelector("mark")).toBe(marks[0]);
expect(document.activeElement).toBe(input);
});
it("cycles backward on Shift+Enter and handles one match", () => {
const { target, input, count } = setup("id + id + id");
input.value = "id";
input.dispatchEvent(new Event("input"));
pressKey(input, "Enter", { shiftKey: true });
expect(count.textContent).toBe("3 / 3");
pressKey(input, "Enter", { shiftKey: true });
expect(count.textContent).toBe("2 / 3");
input.value = "id + id + id";
input.dispatchEvent(new Event("input"));
pressKey(input, "Enter");
pressKey(input, "Enter", { shiftKey: true });
expect(count.textContent).toBe("1 / 1");
expect(activeMarks(target)).toHaveLength(1);
});
it("counts cross-span matches once and navigates every fragment together", () => {
const { target, input, count } = setup("<span>order</span><span>_id</span><br><span>order_id</span>");
input.value = "order_id";
input.dispatchEvent(new Event("input"));
const marks = [...target.querySelectorAll("mark")];
expect(marks).toHaveLength(3);
expect(count.textContent).toBe("1 / 2");
expect(activeMarks(target)).toEqual([marks[0], marks[1]]);
pressKey(input, "Enter");
expect(count.textContent).toBe("2 / 2");
expect(activeMarks(target)).toEqual([marks[2]]);
pressKey(input, "Enter");
expect(activeMarks(target)).toEqual([marks[0], marks[1]]);
expect(target.querySelectorAll("span")).toHaveLength(3);
expect(target.querySelectorAll("br")).toHaveLength(1);
});
it("resets the active match when the query changes and hides the count on Escape", () => {
const { target, controller, input, count } = setup();
expect(count.hidden).toBe(true);
input.value = "id";
input.dispatchEvent(new Event("input"));
pressKey(input, "Enter");
expect(count.textContent).toBe("2 / 2");
input.value = "order";
input.dispatchEvent(new Event("input"));
expect(count.textContent).toBe("1 / 2");
expect(activeMarks(target)[0]).toBe(target.querySelector("mark"));
pressKey(input, "Escape");
expect(input.value).toBe("");
expect(count.hidden).toBe(true);
expect(count.textContent).toBe("");
expect(controller.status.hidden).toBe(true);
expect(target.textContent).toBe(DDL);
expect(target.querySelector("mark")).toBeNull();
});
it("shows zero matches without scrolling and ignores navigation for blank queries", () => {
const { target, controller, input, count } = setup();
const scroll = vi.spyOn(HTMLElement.prototype, "scrollIntoView");
input.value = "missing";
input.dispatchEvent(new Event("input"));
pressKey(input, "Enter");
pressKey(input, "Enter", { shiftKey: true });
expect(count.hidden).toBe(false);
expect(count.textContent).toBe("0 / 0");
expect(controller.status.hidden).toBe(false);
expect(activeMarks(target)).toHaveLength(0);
input.value = " ";
input.dispatchEvent(new Event("input"));
pressKey(input, "Enter");
expect(count.hidden).toBe(true);
expect(controller.status.hidden).toBe(true);
expect(scroll).not.toHaveBeenCalled();
});
it.each(["Enter", "Escape"])("preserves %s while the keyboard event is composing", (key) => {
const { target, input } = setup("id + id");
input.value = "id";
input.dispatchEvent(new Event("input"));
const firstMatch = activeMarks(target)[0];
const event = pressKey(input, key, { isComposing: true });
expect(event.defaultPrevented).toBe(false);
expect(input.value).toBe("id");
expect(activeMarks(target)).toEqual([firstMatch]);
});
it("uses composition lifecycle events when a key event omits isComposing", () => {
const { target, input, count } = setup("id + id");
input.value = "id";
input.dispatchEvent(new Event("input"));
const firstMatch = activeMarks(target)[0];
input.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true }));
expect(pressKey(input, "Enter").defaultPrevented).toBe(false);
expect(pressKey(input, "Escape").defaultPrevented).toBe(false);
expect(input.value).toBe("id");
expect(activeMarks(target)).toEqual([firstMatch]);
input.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true }));
pressKey(input, "Enter");
expect(count.textContent).toBe("2 / 2");
});
it("ignores the legacy IME key code and does not clear on Escape keyup", () => {
const { target, input } = setup("id + id");
input.value = "id";
input.dispatchEvent(new Event("input"));
const firstMatch = activeMarks(target)[0];
expect(pressKey(input, "Enter", { keyCode: 229 }).defaultPrevented).toBe(false);
input.dispatchEvent(new KeyboardEvent("keyup", { key: "Escape", bubbles: true }));
expect(input.value).toBe("id");
expect(activeMarks(target)).toEqual([firstMatch]);
});
it.each([{ ctrlKey: true }, { metaKey: true }, { altKey: true }])("does not navigate or leak modified Enter with %j", (modifiers) => {
const { target, controller, input } = setup("id + id");
const keydown = vi.fn();
const keyup = vi.fn();
controller.element.addEventListener("keydown", keydown);
controller.element.addEventListener("keyup", keyup);
input.value = "id";
input.dispatchEvent(new Event("input"));
const firstMatch = activeMarks(target)[0];
pressKey(input, "Enter", modifiers);
expect(activeMarks(target)).toEqual([firstMatch]);
expect(keydown).not.toHaveBeenCalled();
expect(keyup).not.toHaveBeenCalled();
});
it("stops navigation after destroy", () => {
const { target, controller, input, count } = setup("id + id");
input.value = "id";
input.dispatchEvent(new Event("input"));
const firstMatch = activeMarks(target)[0];
controller.destroy();
expect(pressKey(input, "Enter").defaultPrevented).toBe(false);
expect(count.textContent).toBe("1 / 2");
expect(activeMarks(target)).toEqual([firstMatch]);
});
});
+64 -20
View File
@@ -15,6 +15,7 @@
*/
const MATCH_ATTRIBUTE = "data-sql-hover-search-match";
const MATCH_INDEX_ATTRIBUTE = "data-sql-hover-search-match-index";
const ACTIVE_ATTRIBUTE = "data-sql-hover-search-active";
export interface HoverSearchMatch {
@@ -53,8 +54,8 @@ export function findHoverSearchMatches(text: string, query: string): HoverSearch
* 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.
* Returns the created `<mark>` elements in document order, grouped by match
* index. Every fragment of the first match is tagged active.
*/
export function applyHoverSearchHighlights(container: HTMLElement, matches: HoverSearchMatch[]): HTMLElement[] {
if (matches.length === 0) return [];
@@ -71,19 +72,26 @@ export function applyHoverSearchHighlights(container: HTMLElement, matches: Hove
}
const marks: HTMLElement[] = [];
let firstMatchIndex = 0;
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;
while (firstMatchIndex < matches.length && matches[firstMatchIndex].end <= start) firstMatchIndex++;
if (firstMatchIndex === matches.length) break;
if (start === end || matches[firstMatchIndex].start >= end) continue;
const data = node.data;
const fragment = document.createDocumentFragment();
let cursor = 0;
for (const { from, to } of segments) {
for (let matchIndex = firstMatchIndex; matchIndex < matches.length && matches[matchIndex].start < end; matchIndex++) {
const match = matches[matchIndex];
const from = Math.max(match.start, start) - start;
const to = Math.min(match.end, end) - start;
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.setAttribute(MATCH_INDEX_ATTRIBUTE, String(matchIndex));
if (matchIndex === 0) mark.setAttribute(ACTIVE_ATTRIBUTE, "true");
mark.className =
"rounded-[2px] bg-yellow-300/70 px-px text-inherit dark:bg-yellow-500/40 data-[sql-hover-search-active=true]:bg-yellow-400/90 data-[sql-hover-search-active=true]:outline data-[sql-hover-search-active=true]:outline-1 data-[sql-hover-search-active=true]:outline-yellow-600 dark:data-[sql-hover-search-active=true]:bg-yellow-500/70 dark:data-[sql-hover-search-active=true]:outline-yellow-400";
mark.textContent = data.slice(from, to);
fragment.appendChild(mark);
marks.push(mark);
@@ -93,7 +101,6 @@ export function applyHoverSearchHighlights(container: HTMLElement, matches: Hove
node.replaceWith(fragment);
}
marks[0]?.setAttribute(ACTIVE_ATTRIBUTE, "true");
return marks;
}
@@ -140,7 +147,7 @@ export function createHoverSearch(options: HoverSearchOptions): HoverSearchContr
const element = document.createElement("div");
element.dataset.sqlHoverSearch = "true";
element.className = "mt-2 flex-none";
element.className = "mt-2 flex flex-none items-center gap-2";
const input = document.createElement("input");
input.type = "text";
@@ -149,18 +156,41 @@ export function createHoverSearch(options: HoverSearchOptions): HoverSearchContr
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";
input.className = "min-w-0 flex-1 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 matchCount = document.createElement("span");
matchCount.dataset.sqlHoverSearchCount = "true";
matchCount.className = "shrink-0 text-[11px] tabular-nums text-muted-foreground";
matchCount.setAttribute("role", "status");
matchCount.setAttribute("aria-live", "polite");
matchCount.setAttribute("aria-atomic", "true");
matchCount.hidden = true;
element.appendChild(matchCount);
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;
let matchGroups: HTMLElement[][] = [];
let activeMatchIndex = -1;
let isComposing = false;
const activateMatch = (index: number) => {
for (const mark of matchGroups[activeMatchIndex] ?? []) mark.removeAttribute(ACTIVE_ATTRIBUTE);
activeMatchIndex = index;
for (const mark of matchGroups[index]) mark.setAttribute(ACTIVE_ATTRIBUTE, "true");
matchCount.textContent = `${index + 1} / ${matchGroups.length}`;
matchGroups[index][0]?.scrollIntoView({ block: "nearest" });
};
const runSearch = () => {
// Restore the pristine highlighted DDL, then re-apply match highlights.
target.innerHTML = originalHtml;
matchGroups = [];
activeMatchIndex = -1;
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
@@ -168,23 +198,27 @@ export function createHoverSearch(options: HoverSearchOptions): HoverSearchContr
// `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 hasQuery = query.trim().length > 0;
matchCount.hidden = !hasQuery;
matchCount.textContent = hasQuery ? "0 / 0" : "";
status.hidden = !hasQuery || matches.length > 0;
if (matches.length === 0) return;
const marks = applyHoverSearchHighlights(target, matches);
marks[0]?.scrollIntoView({ block: "nearest" });
matchGroups = Array.from({ length: matches.length }, () => []);
for (const mark of marks) matchGroups[Number(mark.getAttribute(MATCH_INDEX_ATTRIBUTE))].push(mark);
activateMatch(0);
};
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.type !== "keydown" || isComposing || event.isComposing || event.keyCode === 229) return;
if (event.key === "Enter" && !event.ctrlKey && !event.metaKey && !event.altKey) {
event.preventDefault();
if (matchGroups.length > 0) activateMatch((activeMatchIndex + (event.shiftKey ? -1 : 1) + matchGroups.length) % matchGroups.length);
}
if (event.key === "Escape" && input.value) {
event.preventDefault();
input.value = "";
@@ -194,10 +228,18 @@ export function createHoverSearch(options: HoverSearchOptions): HoverSearchContr
const stopPointer = (event: Event) => {
event.stopPropagation();
};
const startComposition = () => {
isComposing = true;
};
const endComposition = () => {
isComposing = false;
};
input.addEventListener("input", runSearch);
input.addEventListener("keydown", stopKeyboard);
input.addEventListener("keyup", stopKeyboard);
input.addEventListener("compositionstart", startComposition);
input.addEventListener("compositionend", endComposition);
input.addEventListener("pointerdown", stopPointer);
input.addEventListener("mousedown", stopPointer);
@@ -211,6 +253,8 @@ export function createHoverSearch(options: HoverSearchOptions): HoverSearchContr
input.removeEventListener("input", runSearch);
input.removeEventListener("keydown", stopKeyboard);
input.removeEventListener("keyup", stopKeyboard);
input.removeEventListener("compositionstart", startComposition);
input.removeEventListener("compositionend", endComposition);
input.removeEventListener("pointerdown", stopPointer);
input.removeEventListener("mousedown", stopPointer);
},