feat(grid): add DDL search navigation

This commit is contained in:
zipg 2026-07-23 21:50:42 +08:00 committed by GitHub
parent 438164dd64
commit c31d403af3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 113 additions and 11 deletions

View File

@ -8,6 +8,7 @@ import {
Upload,
Trash2,
ChevronDown,
ChevronUp,
ChevronLeft,
ChevronRight,
Search,
@ -6690,6 +6691,57 @@ const showTableInfo = ref(false);
const activeTableInfoTab = ref<TableInfoTab>(settingsStore.editorSettings.tableInfoActiveTab);
const ddlContent = ref("");
const ddlPreRef = ref<HTMLPreElement | null>(null);
const ddlSearchMatchCount = ref(0);
const ddlSearchMatchIndex = ref(0);
function scrollDdlSearchMatchIntoView(match: HTMLElement) {
const pre = ddlPreRef.value;
if (!pre) return;
const preRect = pre.getBoundingClientRect();
const matchRect = match.getBoundingClientRect();
pre.scrollTop += matchRect.top - preRect.top - (pre.clientHeight - matchRect.height) / 2;
pre.scrollLeft += matchRect.left - preRect.left - (pre.clientWidth - matchRect.width) / 2;
}
function syncDdlSearchMatches(scrollToActive = false) {
const pre = ddlPreRef.value;
if (!pre || activeTableInfoTab.value !== "ddl" || !searchQuery.value) {
ddlSearchMatchCount.value = 0;
ddlSearchMatchIndex.value = 0;
return;
}
const matches = Array.from(pre.querySelectorAll<HTMLElement>("mark.ddl-search-match"));
ddlSearchMatchCount.value = matches.length;
if (matches.length === 0) {
ddlSearchMatchIndex.value = 0;
return;
}
ddlSearchMatchIndex.value = Math.min(ddlSearchMatchIndex.value, matches.length - 1);
matches.forEach((match, index) => match.classList.toggle("ddl-search-match-active", index === ddlSearchMatchIndex.value));
const activeMatch = matches[ddlSearchMatchIndex.value];
if (scrollToActive && activeMatch) scrollDdlSearchMatchIntoView(activeMatch);
}
function navigateDdlSearch(delta: -1 | 1) {
const count = ddlSearchMatchCount.value;
if (count === 0) return;
ddlSearchMatchIndex.value = (ddlSearchMatchIndex.value + delta + count) % count;
syncDdlSearchMatches(true);
}
function onTableInfoSearchKeydown(e: KeyboardEvent) {
if (e.key === "Escape") {
searchQuery.value = "";
return;
}
if (e.key !== "Enter" || activeTableInfoTab.value !== "ddl") return;
e.preventDefault();
navigateDdlSearch(e.shiftKey ? -1 : 1);
}
function onDdlKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === "a") {
e.preventDefault();
@ -6745,11 +6797,16 @@ watch(activeTableInfoTab, () => {
});
watch([activeTableInfoTab, ddlLoading], ([tab, loading]) => {
if (tab === "ddl" && !loading) {
void nextTick(() => {
ddlPreRef.value?.focus();
});
if (tab !== "ddl") return;
if (loading) {
ddlSearchMatchCount.value = 0;
ddlSearchMatchIndex.value = 0;
return;
}
void nextTick(() => {
ddlPreRef.value?.focus();
syncDdlSearchMatches(true);
});
});
watch(
@ -7216,10 +7273,20 @@ const filteredDdlContent = computed(() => {
const regex = new RegExp(`(${escaped})`, "gi");
// Match only text between > and < (text nodes), then replace the search term within those spans
return html.replace(/>([^<]*)</g, (_, text) => {
return `>${text.replace(regex, "<mark>$1</mark>")}<`;
return `>${text.replace(regex, '<mark class="ddl-search-match">$1</mark>')}<`;
});
});
watch(
[filteredDdlContent, searchQuery],
async () => {
ddlSearchMatchIndex.value = 0;
await nextTick();
syncDdlSearchMatches(true);
},
{ flush: "post" },
);
defineExpose({
useTransaction,
transactionActive,
@ -8616,12 +8683,25 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</div>
<div class="px-2 py-1.5 border-b shrink-0 bg-background">
<div class="relative">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<input v-model="searchQuery" :placeholder="t('grid.tableInfoSearch')" class="w-full h-7 pl-7 pr-6 text-xs bg-muted/50 rounded border border-border focus:outline-none focus:border-primary/50" @keydown.escape="searchQuery = ''" />
<button v-if="searchQuery" class="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" @click="searchQuery = ''">
<X class="w-3 h-3" />
</button>
<div class="flex min-w-0 items-center gap-1">
<div class="relative min-w-0 flex-1">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<input v-model="searchQuery" :placeholder="t('grid.tableInfoSearch')" class="w-full h-7 pl-7 pr-6 text-xs bg-muted/50 rounded border border-border focus:outline-none focus:border-primary/50" @keydown="onTableInfoSearchKeydown" />
<button v-if="searchQuery" type="button" class="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" @click="searchQuery = ''">
<X class="w-3 h-3" />
</button>
</div>
<template v-if="activeTableInfoTab === 'ddl'">
<span class="w-9 shrink-0 text-center text-[11px] tabular-nums text-muted-foreground">
{{ searchQuery ? (ddlSearchMatchCount > 0 ? `${ddlSearchMatchIndex + 1}/${ddlSearchMatchCount}` : "0") : "" }}
</span>
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :title="t('editor.search.prevMatch')" :aria-label="t('editor.search.prevMatch')" :disabled="ddlSearchMatchCount === 0" @click="navigateDdlSearch(-1)">
<ChevronUp class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :title="t('editor.search.nextMatch')" :aria-label="t('editor.search.nextMatch')" :disabled="ddlSearchMatchCount === 0" @click="navigateDdlSearch(1)">
<ChevronDown class="h-3.5 w-3.5" />
</Button>
</template>
</div>
</div>
@ -9725,4 +9805,16 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
color: rgb(213 111 44);
color: oklch(0.65 0.15 50);
}
.ddl-code :deep(.ddl-search-match) {
border-radius: 2px;
background: var(--data-grid-cell-search-bg);
color: inherit;
padding: 0;
}
.ddl-code :deep(.ddl-search-match-active) {
background: var(--data-grid-cell-current-search-bg);
outline: 1px solid var(--data-grid-cell-current-search-border);
}
</style>

View File

@ -0,0 +1,10 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8");
describe("DataGrid DDL search navigation", () => {
it("resets navigation when the raw search query changes", () => {
expect(dataGridSource).toMatch(/watch\(\s*\[filteredDdlContent, searchQuery\],/);
});
});