feat(grid): color quick condition bar keywords, fields and values

This commit is contained in:
Allen
2026-08-26 13:52:41 +08:00
committed by GitHub
parent 4c28492f2c
commit b37a1f8538
3 changed files with 256 additions and 2 deletions
@@ -2,6 +2,7 @@
import { computed, nextTick, onMounted, onUnmounted, ref, useId, watch, type CSSProperties } from "vue";
import { ChevronDown, X } from "@lucide/vue";
import { completeDataGridConditionQuote, useDataGridConditionEditor, type DataGridConditionColumnOption, type DataGridConditionSuggestion, type DataGridConditionSuggestionProvider } from "@/composables/useDataGridConditionEditor";
import { tokenizeDataGridCondition, type DataGridConditionTokenType } from "@/lib/dataGrid/dataGridConditionHighlight";
import { getDataGridConditionSuggestionPosition, getDataGridConditionSuggestionPreferredWidth } from "@/lib/dataGrid/dataGridConditionSuggestionPosition";
import type { DataGridConditionHistoryKind, DataGridConditionHistoryScope } from "@/lib/dataGrid/dataGridConditionHistory";
@@ -73,6 +74,25 @@ const editor = useDataGridConditionEditor({
const activeEditor = computed(() => overlayRef.value ?? inputRef.value);
const hasValue = computed(() => modelValue.value.trim().length > 0);
// Syntax highlight tokens rendered in a layer below the (transparent-text)
// textarea, so keywords / fields / values are colored without losing caret
// and selection behavior.
const highlightTokens = computed(() => tokenizeDataGridCondition(modelValue.value));
const highlightScrollLeft = ref(0);
const highlightScrollTop = ref(0);
const collapsedHighlightStyle = computed<CSSProperties>(() => ({ transform: `translateX(${-highlightScrollLeft.value}px)` }));
const expandedHighlightStyle = computed<CSSProperties>(() => ({ transform: `translate(${-highlightScrollLeft.value}px, ${-highlightScrollTop.value}px)` }));
function highlightTokenClass(type: DataGridConditionTokenType): string | undefined {
if (type === "plain") return undefined;
return `data-grid-condition-token--${type}`;
}
function onEditorScroll(event: Event) {
const target = event.currentTarget as HTMLTextAreaElement;
highlightScrollLeft.value = target.scrollLeft;
highlightScrollTop.value = target.scrollTop;
}
const emptyHistoryText = computed(() => (modelValue.value.trim() ? props.historyNoMatchesText : props.historyEmptyText));
const activeSuggestionId = computed(() => (editor.highlightedIndex.value >= 0 ? `${suggestionListId}-${editor.highlightedIndex.value}` : undefined));
const suggestionPreferredWidth = computed(() => getDataGridConditionSuggestionPreferredWidth(editor.suggestions.value));
@@ -501,6 +521,9 @@ defineExpose({ focus, dismiss: editor.dismiss, rememberHistory: editor.rememberH
{{ props.kind === "where" ? "WHERE" : "ORDER BY" }}
</span>
<div class="relative h-6 min-w-0 flex-1 overflow-hidden">
<div v-if="!composing" aria-hidden="true" class="data-grid-condition-highlight pointer-events-none absolute left-0 top-0" :style="collapsedHighlightStyle">
<span v-for="(token, tokenIndex) in highlightTokens" :key="tokenIndex" :class="highlightTokenClass(token.type)">{{ token.text }}</span>
</div>
<textarea
ref="inputRef"
v-model="modelValue"
@@ -518,7 +541,7 @@ defineExpose({ focus, dismiss: editor.dismiss, rememberHistory: editor.rememberH
:aria-controls="suggestionListId"
:aria-activedescendant="activeSuggestionId"
class="data-grid-topbar-condition-input absolute inset-x-0 top-0 h-6 min-w-0 resize-none bg-transparent outline-none"
:class="[props.kind === 'where' ? 'data-grid-topbar-condition-input--where' : 'data-grid-topbar-condition-input--order', { 'data-grid-topbar-condition-input--compact': props.compact }]"
:class="[props.kind === 'where' ? 'data-grid-topbar-condition-input--where' : 'data-grid-topbar-condition-input--order', { 'data-grid-topbar-condition-input--compact': props.compact, 'data-grid-condition-input--transparent-text': !composing }]"
style="height: 24px"
@focus="onFocus"
@blur="scheduleCollapse"
@@ -528,6 +551,7 @@ defineExpose({ focus, dismiss: editor.dismiss, rememberHistory: editor.rememberH
@compositionstart="onCompositionStart"
@compositionend="onCompositionEnd"
@input="onInput"
@scroll="onEditorScroll"
@contextmenu.stop
@keydown="onKeydown"
/>
@@ -542,6 +566,9 @@ defineExpose({ focus, dismiss: editor.dismiss, rememberHistory: editor.rememberH
<Teleport to="body">
<div v-if="expanded" class="data-grid-topbar-condition-pane--expanded fixed z-[80] flex min-w-0 items-start gap-1" :style="overlayStyle">
<div v-if="!composing" aria-hidden="true" class="data-grid-condition-highlight data-grid-condition-highlight--expanded pointer-events-none absolute" :style="expandedHighlightStyle">
<span v-for="(token, tokenIndex) in highlightTokens" :key="tokenIndex" :class="highlightTokenClass(token.type)">{{ token.text }}</span>
</div>
<textarea
ref="overlayRef"
v-model="modelValue"
@@ -554,7 +581,7 @@ defineExpose({ focus, dismiss: editor.dismiss, rememberHistory: editor.rememberH
:aria-controls="suggestionListId"
:aria-activedescendant="activeSuggestionId"
class="data-grid-topbar-condition-input data-grid-topbar-condition-input--expanded absolute resize-none outline-none"
:class="[props.kind === 'where' ? 'data-grid-topbar-condition-input--where' : 'data-grid-topbar-condition-input--order', { 'data-grid-topbar-condition-input--compact': props.compact }]"
:class="[props.kind === 'where' ? 'data-grid-topbar-condition-input--where' : 'data-grid-topbar-condition-input--order', { 'data-grid-topbar-condition-input--compact': props.compact, 'data-grid-condition-input--transparent-text': !composing }]"
@blur="scheduleCollapse"
@focus="onFocus"
@click="onSelectionChange"
@@ -563,6 +590,7 @@ defineExpose({ focus, dismiss: editor.dismiss, rememberHistory: editor.rememberH
@compositionstart="onCompositionStart"
@compositionend="onCompositionEnd"
@input="onInput"
@scroll="onEditorScroll"
@contextmenu.stop
@keydown="onKeydown"
/>
@@ -804,4 +832,67 @@ defineExpose({ focus, dismiss: editor.dismiss, rememberHistory: editor.rememberH
:global(.dark .data-grid-topbar-condition-input--order.data-grid-topbar-condition-input--compact::placeholder) {
color: rgb(253 186 116 / 70%);
}
/* Syntax highlight layer: sits below the transparent-text textarea and colors
condition keywords / fields / values while the real caret and selection keep
working in the textarea above it. */
.data-grid-condition-highlight {
box-sizing: border-box;
width: max-content;
min-width: 100%;
height: 24px;
padding: 0 0.125rem;
font-family: var(--data-grid-condition-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
font-size: 0.875rem;
font-variant-ligatures: none;
font-feature-settings:
"liga" 0,
"calt" 0;
line-height: 1.5rem;
white-space: pre;
}
.data-grid-condition-highlight--expanded {
top: var(--data-grid-condition-input-top);
bottom: 0.125rem;
left: 0.5rem;
width: calc(100% - 1rem + var(--data-grid-expanded-scrollbar-offset));
min-width: 0;
height: auto;
margin-right: calc(-1 * var(--data-grid-expanded-scrollbar-offset));
padding: 0 calc(var(--data-grid-condition-suffix-width) + 0.5rem) 0.0625rem 0.125rem;
text-indent: var(--data-grid-condition-prefix-indent);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.data-grid-condition-input--transparent-text {
color: transparent !important;
caret-color: var(--foreground);
}
.data-grid-condition-token--keyword {
color: rgb(37 99 235);
font-weight: 600;
}
.data-grid-condition-token--field {
color: rgb(225 29 72);
}
.data-grid-condition-token--value {
color: rgb(13 148 136);
}
:global(.dark .data-grid-condition-token--keyword) {
color: rgb(96 165 250);
}
:global(.dark .data-grid-condition-token--field) {
color: rgb(251 113 133);
}
:global(.dark .data-grid-condition-token--value) {
color: rgb(45 212 191);
}
</style>
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { tokenizeDataGridCondition, type DataGridConditionToken } from "@/lib/dataGrid/dataGridConditionHighlight";
function pairs(text: string): string[] {
return tokenizeDataGridCondition(text).map((token) => `${token.type}:${token.text}`);
}
describe("tokenizeDataGridCondition", () => {
it("colors keywords, fields and values in a where condition", () => {
expect(pairs("name = 'dbx' and age >= 18")).toEqual(["field:name", "plain: = ", "value:'dbx'", "plain: ", "keyword:and", "plain: ", "field:age", "plain: >= ", "value:18"]);
});
it("matches keywords case-insensitively", () => {
expect(pairs("a AND b OR c")).toEqual(["field:a", "plain: ", "keyword:AND", "plain: ", "field:b", "plain: ", "keyword:OR", "plain: ", "field:c"]);
});
it("treats in/is/null/between/like as keywords", () => {
expect(pairs("id in (1, 2) and deleted_at is not null")).toEqual(["field:id", "plain: ", "keyword:in", "plain: (", "value:1", "plain:, ", "value:2", "plain:) ", "keyword:and", "plain: ", "field:deleted_at", "plain: ", "keyword:is", "plain: ", "keyword:not", "plain: ", "keyword:null"]);
});
it("colors asc/desc in order by", () => {
expect(pairs("created_at desc, id asc")).toEqual(["field:created_at", "plain: ", "keyword:desc", "plain:, ", "field:id", "plain: ", "keyword:asc"]);
});
it("keeps quoted identifiers as fields", () => {
expect(pairs("`order` = 1 and [user name] = 'x'")).toEqual(["field:`order`", "plain: = ", "value:1", "plain: ", "keyword:and", "plain: ", "field:[user name]", "plain: = ", "value:'x'"]);
});
it("keeps escaped quotes inside string values", () => {
expect(pairs("name = 'it''s'")).toEqual(["field:name", "plain: = ", "value:'it''s'"]);
});
it("treats double-quoted runs as values", () => {
expect(pairs('name = "abc"')).toEqual(["field:name", "plain: = ", 'value:"abc"']);
});
it("reads decimal and exponent numbers as values", () => {
expect(pairs("score > 0.5 and ratio <= 1e-3")).toEqual(["field:score", "plain: > ", "value:0.5", "plain: ", "keyword:and", "plain: ", "field:ratio", "plain: <= ", "value:1e-3"]);
});
it("keeps dotted and unicode names as fields", () => {
expect(pairs("t.名称 = 'v'")).toEqual(["field:t.名称", "plain: = ", "value:'v'"]);
});
it("does not treat keyword-looking prefixes as keywords", () => {
expect(pairs("android = 1")).toEqual(["field:android", "plain: = ", "value:1"]);
});
it("returns an empty list for empty input", () => {
expect(tokenizeDataGridCondition("")).toEqual([]);
});
it("preserves the full text across tokens", () => {
const text = "name = 'dbx' and (age >= 18 or city like 'sh%') order by id desc";
const joined = tokenizeDataGridCondition(text)
.map((token: DataGridConditionToken) => token.text)
.join("");
expect(joined).toBe(text);
});
});
@@ -0,0 +1,103 @@
/**
* Tokenizer for the data grid quick-condition bar (WHERE / ORDER BY inputs).
* Splits the raw text into highlightable tokens so the editor can color
* keywords (AND/OR/...), condition fields, and values differently.
*/
export type DataGridConditionTokenType = "keyword" | "field" | "value" | "plain";
export interface DataGridConditionToken {
type: DataGridConditionTokenType;
text: string;
}
const CONDITION_KEYWORDS = new Set(["and", "or", "not", "like", "ilike", "rlike", "regexp", "in", "is", "null", "isnull", "notnull", "between", "exists", "true", "false", "asc", "desc", "case", "when", "then", "else", "end", "escape"]);
function isDigit(char: string): boolean {
return char >= "0" && char <= "9";
}
function isIdentifierStart(char: string): boolean {
return /[\p{L}_$]/u.test(char);
}
function isIdentifierPart(char: string): boolean {
return /[\p{L}\p{N}_$.]/u.test(char);
}
/** Reads a quoted run starting at `start`, honoring backslash and doubled-quote escapes. Returns the end index (exclusive). */
function quotedEnd(text: string, start: number, quote: string): number {
let i = start + 1;
while (i < text.length) {
const char = text[i]!;
if (char === "\\" && quote !== "]") {
i += 2;
continue;
}
if (char === quote) {
// Doubled quote is an escaped literal quote inside the run.
if (text[i + 1] === quote && quote !== "]") {
i += 2;
continue;
}
return i + 1;
}
if (quote === "]" && char === "]") return i + 1;
i++;
}
return text.length;
}
export function tokenizeDataGridCondition(text: string): DataGridConditionToken[] {
const tokens: DataGridConditionToken[] = [];
let i = 0;
const push = (type: DataGridConditionTokenType, value: string) => {
const last = tokens[tokens.length - 1];
if (last && last.type === type) {
last.text += value;
return;
}
tokens.push({ type, text: value });
};
while (i < text.length) {
const char = text[i]!;
if (char === "'" || char === '"' || char === "`" || char === "[") {
const end = quotedEnd(text, i, char === "[" ? "]" : char);
// Backtick / bracket quoting denotes identifiers; quotes denote values.
push(char === "`" || char === "[" ? "field" : "value", text.slice(i, end));
i = end;
continue;
}
if (isDigit(char) || (char === "." && isDigit(text[i + 1] ?? ""))) {
let end = i;
while (end < text.length && /[\p{N}.]/u.test(text[end]!)) end++;
// Exponent notation: 1e5 / 1.2e-3
if ((text[end] === "e" || text[end] === "E") && (isDigit(text[end + 1] ?? "") || ((text[end + 1] === "+" || text[end + 1] === "-") && isDigit(text[end + 2] ?? "")))) {
end += text[end + 1] === "+" || text[end + 1] === "-" ? 2 : 1;
while (end < text.length && isDigit(text[end]!)) end++;
}
push("value", text.slice(i, end));
i = end;
continue;
}
if (isIdentifierStart(char)) {
let end = i + 1;
while (end < text.length && isIdentifierPart(text[end]!)) end++;
const word = text.slice(i, end);
const isKeyword = !word.includes(".") && !word.includes("$") && CONDITION_KEYWORDS.has(word.toLowerCase());
push(isKeyword ? "keyword" : "field", word);
i = end;
continue;
}
push("plain", char);
i++;
}
return tokens;
}