mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
feat(grid): color quick condition bar keywords, fields and values
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user