feat(grid): add row on arrow down and revert with escape

This commit is contained in:
Abeautifulsnow
2026-10-16 15:05:55 +08:00
committed by GitHub
parent a3c282bb49
commit e029e1a1c6
12 changed files with 677 additions and 27 deletions
+66 -12
View File
@@ -270,7 +270,7 @@ import {
} from "@/lib/dataGrid/canvasDataGridRenderer";
import { resolveDataGridRowNumberLabel } from "@/lib/dataGrid/dataGridRowNumber";
import { resolveCrosshairTarget, type CrosshairTarget } from "@/lib/dataGrid/crosshairHighlight";
import { DATA_GRID_DARK_STRIPED_ROW_BG, DATA_GRID_LIGHT_STRIPED_ROW_BG, dataGridActiveRowBackground } from "@/lib/dataGrid/dataGridPaintTheme";
import { DATA_GRID_DARK_NEW_ROW_BG, DATA_GRID_DARK_STRIPED_ROW_BG, DATA_GRID_LIGHT_NEW_ROW_BG, DATA_GRID_LIGHT_STRIPED_ROW_BG, dataGridActiveRowBackground } from "@/lib/dataGrid/dataGridPaintTheme";
import { createRowLowerTextCache } from "@/lib/dataGrid/dataGridRowLowerText";
import { dataGridPreviewLabelKey, dataGridSaveActionMode, dataGridSaveToolbarState } from "@/lib/dataGrid/dataGridSaveUi";
import { buildDataGridSavedRowRefreshPlan, dataGridSavedRowRefreshPatches } from "@/lib/dataGrid/dataGridSavedRowRefresh";
@@ -4609,6 +4609,7 @@ const {
applyCellValue,
stageCellReplacements,
restoreCellValue,
cancelUntouchedPendingRow,
cancelEdit,
onEditKeydown,
addRowsInBatches: addEditorRowsInBatches,
@@ -7765,22 +7766,25 @@ function rowCellsUseSelectionVisual(rowId: number): boolean {
function dataGridRowStyle(item: RowItem): CSSProperties {
const dark = isDark.value || (typeof document !== "undefined" && document.documentElement.classList.contains("dark"));
const activeRowBg = dataGridActiveRowBackground(dark);
// 新增行/斑马纹行底色走 CSS 变量:DOM 与 Canvas(canvasDataGridRenderer 的 theme.rowNew /
// theme.rowMuted)共用同一色源,避免两侧各写一份字面色后悄悄漂移。
const newRowBg = `var(--data-grid-row-new-bg, ${dark ? DATA_GRID_DARK_NEW_ROW_BG : DATA_GRID_LIGHT_NEW_ROW_BG})`;
const mutedRowBg = `var(--data-grid-row-muted-bg, ${dark ? DATA_GRID_DARK_STRIPED_ROW_BG : DATA_GRID_LIGHT_STRIPED_ROW_BG})`;
// 待插入行底色优先于活动行底色(isRowActive 会排除 isNew):光标总落在刚新增的那一行上
// (新增后立即进入编辑),若让活动行底色盖掉它,「这行还没保存」恰好在这时看不见(#11634)。
// 光标位置仍由行号列的活动色与选中单元格表达。Canvas 侧同序(resolveCanvasDataGridRowFill)。
const rowBg = item.isDeleted
? dark
? "rgb(55, 31, 32)"
: "rgb(255, 244, 244)"
: isRowActive(item.displayIndex)
: isRowActive(item.displayIndex) && !item.isNew
? activeRowBg
: item.isNew
? dark
? "rgb(51, 51, 55)"
: "rgb(243, 243, 243)"
? newRowBg
: item.isDraft
? dark
? "rgb(51, 51, 55)"
: "rgb(243, 243, 243)"
? mutedRowBg
: dataGridStripedRows.value && item.displayIndex % 2 === 1
? settingsStore.editorSettings.dataGridZebraRowBg?.trim() || `var(--data-grid-row-muted-bg, ${dark ? DATA_GRID_DARK_STRIPED_ROW_BG : DATA_GRID_LIGHT_STRIPED_ROW_BG})`
? settingsStore.editorSettings.dataGridZebraRowBg?.trim() || mutedRowBg
: "var(--data-grid-background)";
const rowNumberBg =
item.isNew && item.status === "clean"
@@ -10753,6 +10757,44 @@ function openCellDetailSearch(): boolean {
return getDetailEditor()?.openSearch() || cellDetailPanelRef.value?.openSearch() || false;
}
// #11634:光标停在当前显示的最后一行时,再按一次下键 = 「新增行」(固定追加到结果末尾,
// 不跟随工具栏的插入位置设置)。只有「本来就会被 clamp 成空操作」的那次下键会走到这里,
// 所以已有的移动语义(含 Shift 扩展、转置、多格选区)完全不受影响。
function addRowFromArrowDown(): boolean {
// gridClipboardActive 与 insertRows → prepareGridRows 的准入条件保持一致:网格非活动(KeepAlive 隐藏)
// 时不消费按键,避免出现「吃掉了下键却没有新增」的情况。
if (!canInsertRows.value || isSaving.value || isConditionalUpdateActive.value || rowPreparationController || !gridClipboardActive) return false;
if (isTransposeMode.value || editingCell.value) return false;
if (hasRowSelection.value || hasColumnSelection.value || selectedCellCount.value !== 1) return false;
const position = currentSelectedCellPosition();
if (!position || position.rowIndex !== displayRowCount.value - 1) return false;
// 快速录入的草稿行本身就在末尾,它已经是「待录入行」;再加一行会凭空多出第二个空行。
// 注意:草稿行不进 selectedCellCount(useDataGridSelection 的计数语义会跳过 draft 行),
// 所以上面「单格选中」的条件通常已经拦住了它;这里保留一次显式判定作为第二道保险,
// 避免将来放宽选择语义后下键突然在草稿行上追加 pending 行。
if (displayItemAt(position.rowIndex)?.isDraft) return false;
void insertRows(1, "end");
return true;
}
// #11634:网格焦点下的 Esc(单元格编辑器内的 Esc 由 cancelEdit 处理,两段式)。
// 顺序对齐 DBeaver 的 ESC = Reset cell changes:先撤掉「刚新增且从未写入」的整行,
// 再把单格的待保存改动回退为原值。多格 / 整行 / 整列选区不参与:一次 Esc 不应抹掉一整片改动。
function cancelPendingRowOrRestoreCell(): boolean {
if (isTransposeMode.value || editingCell.value || !props.editable) return false;
// 保存进行中不得改动待提交状态:这一轮保存的快照已经捕获了当前值,此刻回退只会让用户以为
// 改动被撤销,数据库仍会写入(同层的 stageCellReplacements / rowPreparationIsCurrent 都做了
// 同样的 !isSaving 门槛)。
if (isSaving.value) return false;
if (hasRowSelection.value || hasColumnSelection.value || selectedCellCount.value !== 1) return false;
const position = currentSelectedCellPosition();
if (!position) return false;
const item = displayItemAt(position.rowIndex);
if (!item || item.isDeleted) return false;
if (cancelUntouchedPendingRow(item.id)) return true;
return restoreCellValue(item.id, actualColumnIndex(position.colIndex));
}
async function onGridKeydown(event: KeyboardEvent) {
if (event.defaultPrevented) return;
@@ -10851,6 +10893,18 @@ async function onGridKeydown(event: KeyboardEvent) {
return;
}
}
// 两者都只在该按键本来是空操作/无绑定时才消费(下键在最后一行被 clamp;Esc 在网格上原本无绑定),
// 因此不会与上面的可配置快捷键、转置分支以及下面的常规移动产生竞争。
if (event.key === "ArrowDown" && !event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey && addRowFromArrowDown()) {
event.preventDefault();
event.stopPropagation();
return;
}
if (event.key === "Escape" && cancelPendingRowOrRestoreCell()) {
event.preventDefault();
event.stopPropagation();
return;
}
if (event.key === "ArrowUp" && moveSelectedCell(-1, 0, event.shiftKey)) {
event.preventDefault();
return;
@@ -16391,7 +16445,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
[data-grid-root] {
--data-grid-background: var(--background-solid, var(--background));
--data-grid-row-muted-bg: rgb(240, 240, 240);
--data-grid-row-new-bg: rgb(243, 243, 243);
--data-grid-row-new-bg: rgb(231, 248, 242);
--data-grid-row-deleted-bg: rgb(255, 244, 244);
--data-grid-cell-active-bg: rgb(219, 234, 254);
--data-grid-cell-crosshair-row-bg: rgb(174, 195, 224);
@@ -16425,7 +16479,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
[data-grid-root].data-grid--dark,
:global(.dark) [data-grid-root] {
--data-grid-row-muted-bg: rgb(40, 40, 43);
--data-grid-row-new-bg: rgb(51, 51, 55);
--data-grid-row-new-bg: rgb(19, 37, 33);
--data-grid-row-deleted-bg: rgb(55, 31, 32);
--data-grid-cell-active-bg: rgb(27, 46, 70);
--data-grid-cell-crosshair-row-bg: rgb(75, 84, 98);
@@ -16460,7 +16514,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
@supports (background: color-mix(in oklab, white 50%, transparent)) {
[data-grid-root] {
--data-grid-row-muted-bg: color-mix(in oklab, var(--muted) 99%, var(--foreground));
--data-grid-row-new-bg: color-mix(in oklab, var(--primary) 5%, transparent);
--data-grid-row-new-bg: color-mix(in oklab, rgb(16 185 129) 10%, var(--background));
--data-grid-row-deleted-bg: color-mix(in oklab, var(--destructive) 5%, transparent);
--data-grid-cell-dirty-bg: color-mix(in oklab, rgb(240 177 0) 10%, transparent);
--data-grid-cell-crosshair-row-bg: color-mix(in srgb, var(--primary) 34%, var(--background));
@@ -0,0 +1,448 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, markRaw, nextTick, ref, type App, type PropType } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import type { ColumnInfo, QueryResult } from "@/types/database";
import { TooltipProvider } from "@/components/ui/tooltip";
// DataGrid.vue imports RecycleScroller directly (no global registration),
// so the module itself must be stubbed for deterministic row rendering.
vi.mock("vue-virtual-scroller", async () => {
const { defineComponent, h } = await import("vue");
return {
RecycleScroller: defineComponent({
props: {
items: {
type: Array as PropType<unknown[]>,
default: () => [],
},
},
setup(props, { attrs, slots }) {
return () =>
h(
"div",
attrs,
props.items.map((item) => slots.default?.({ item })),
);
},
}),
};
});
vi.mock("@/composables/useDataGridColumnResize", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/composables/useDataGridColumnResize")>();
const { ref } = await import("vue");
return {
...actual,
useDataGridColumnResize: () => ({
initColumnWidths: vi.fn(),
onResizeStart: vi.fn(),
autoFitColumn: vi.fn(),
renderedColumnWidths: ref([120, 120]),
totalWidth: ref(240),
columnVars: ref({ "--total-w": "240px" }),
getIsResizing: () => false,
}),
};
});
import DataGrid from "../DataGrid.vue";
import { useSettingsStore } from "@/stores/settingsStore";
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
interface MountOptions {
renderMode?: "dom" | "canvas";
editable?: boolean;
allowInsertRows?: boolean;
quickEntry?: boolean;
rowCount?: number;
}
function mountGrid(options: MountOptions = {}) {
const pinia = createPinia();
setActivePinia(pinia);
const settingsStore = useSettingsStore();
settingsStore.updateEditorSettings({
dataGridRenderMode: "canvas",
dataGridMultiRowTranspose: false,
dataGridHideNullColumns: false,
dataGridStripedRows: true,
dataGridQuickEntry: options.quickEntry ?? false,
});
const rows = Array.from({ length: options.rowCount ?? 2 }, (_, index) => [index + 1, `name-${index}`]);
const result = markRaw<QueryResult>({
columns: ["id", "name"],
rows,
affected_rows: 0,
execution_time_ms: 0,
});
const columns: ColumnInfo[] = [
{ name: "id", data_type: "int", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
{ name: "name", data_type: "varchar", is_nullable: true, column_default: null, is_primary_key: false, extra: null },
];
const host = document.createElement("div");
document.body.append(host);
const grid = ref<{
selectSingleCell: (row: number, col: number) => void;
editSelectedCell: () => boolean;
isSaving: boolean;
}>();
const Root = defineComponent({
setup() {
return () =>
h(
TooltipProvider,
{ delayDuration: 0 },
{
default: () =>
h(DataGrid, {
ref: grid,
result,
databaseType: "mysql",
context: "table-data",
editable: options.editable ?? true,
allowInsertRows: options.allowInsertRows,
connectionId: "connection-1",
database: "app",
tableMeta: { tableName: "users", columns, primaryKeys: ["id"] },
}),
},
);
},
});
const app = createApp(Root);
app.use(pinia);
app.use(i18n);
app.mount(host);
if ((options.renderMode ?? "dom") === "dom") settingsStore.updateEditorSettings({ dataGridRenderMode: "dom" });
const mounted = { app, host, grid };
mountedApps.push({ app, host });
return mounted;
}
async function settle() {
await nextTick();
await Promise.resolve();
await nextTick();
}
function gridRoot(host: HTMLElement): HTMLElement {
const root = host.querySelector<HTMLElement>("[data-grid-root]");
if (!root) throw new Error("Data grid root not found");
return root;
}
function dispatchKey(target: HTMLElement, key: string, modifiers: { ctrlKey?: boolean; metaKey?: boolean; altKey?: boolean; shiftKey?: boolean } = {}): KeyboardEvent {
const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key, ...modifiers });
target.dispatchEvent(event);
return event;
}
function displayRows(host: HTMLElement): HTMLElement[] {
return [...host.querySelectorAll<HTMLElement>(".data-grid-row")];
}
function cellsOf(row: HTMLElement): HTMLElement[] {
return [...row.querySelectorAll<HTMLElement>(".data-grid-cell")];
}
async function selectCellAt(host: HTMLElement, rowIndex: number, visibleColIndex: number) {
const cell = cellsOf(displayRows(host)[rowIndex]!)[visibleColIndex];
if (!cell) throw new Error(`Cell not found: row ${rowIndex} col ${visibleColIndex}`);
cell.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 }));
window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, button: 0 }));
await settle();
}
async function addRowWithArrowDown(host: HTMLElement, rowCount: number) {
await selectCellAt(host, rowCount - 1, 0);
dispatchKey(gridRoot(host), "ArrowDown");
await settle();
}
/** 新增行会立即打开编辑器;用户先收尾(Esc 只取消输入)才能继续操作网格。 */
async function closeOpenEditor(host: HTMLElement) {
const editor = host.querySelector<HTMLElement>(".cell-edit-input");
if (!editor) return;
editor.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Escape" }));
await settle();
}
async function typeIntoOpenEditor(host: HTMLElement, value: string) {
const input = host.querySelector<HTMLInputElement>(".cell-edit-input");
if (!input) throw new Error("Cell editor is not open");
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
await settle();
input.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Enter" }));
await settle();
}
function selectedRowIndex(host: HTMLElement): number | undefined {
const row = displayRows(host).find((candidate) => candidate.querySelector(".cell-selected, .cell-selected-dirty, .cell-selected--single") !== null);
return row ? Number(row.dataset.rowIndex) : undefined;
}
afterEach(() => {
for (const { app, host } of mountedApps.splice(0)) {
app.unmount();
host.remove();
}
});
describe("DataGrid arrow-down insert row", () => {
it("adds a pending row when ArrowDown is pressed on the last displayed row", async () => {
const { host } = mountGrid({ rowCount: 2 });
await settle();
expect(displayRows(host)).toHaveLength(2);
await addRowWithArrowDown(host, 2);
const rows = displayRows(host);
expect(rows).toHaveLength(3);
const newRow = rows[2]!;
// 追加在显示末尾,且被标注为待插入行
expect(newRow.classList.contains("data-grid-row--new")).toBe(true);
// 整行底色来自新增行色源(DOM 与 Canvas 共用同一 CSS 变量)
expect(newRow.getAttribute("style")).toContain("--data-grid-row-new-bg");
// 未填写的新行沿用 "*" 占位
expect(newRow.querySelector(".data-grid-row-number")?.textContent?.trim()).toBe("*");
// 与「新增行」按钮一致:新增后立即进入首列编辑
expect(host.querySelector(".cell-edit-input")).not.toBeNull();
});
it("keeps the normal down-move away from the last row", async () => {
const { host } = mountGrid({ rowCount: 3 });
await settle();
await selectCellAt(host, 0, 0);
expect(selectedRowIndex(host)).toBe(0);
const event = dispatchKey(gridRoot(host), "ArrowDown");
await settle();
expect(event.defaultPrevented).toBe(true);
expect(selectedRowIndex(host)).toBe(1);
expect(displayRows(host)).toHaveLength(3);
});
it("does not add a row when Shift extends the selection on the last row", async () => {
const { host } = mountGrid({ rowCount: 2 });
await settle();
await selectCellAt(host, 1, 0);
dispatchKey(gridRoot(host), "ArrowDown", { shiftKey: true });
await settle();
expect(displayRows(host)).toHaveLength(2);
expect(host.querySelector(".cell-edit-input")).toBeNull();
});
it("does not add a row when the grid is read-only", async () => {
const { host } = mountGrid({ rowCount: 2, editable: false });
await settle();
await selectCellAt(host, 1, 0);
dispatchKey(gridRoot(host), "ArrowDown");
await settle();
expect(displayRows(host)).toHaveLength(2);
expect(host.querySelector(".cell-edit-input")).toBeNull();
});
it("does not add a row when the save target rejects inserts", async () => {
const { host } = mountGrid({ rowCount: 2, allowInsertRows: false });
await settle();
await selectCellAt(host, 1, 0);
dispatchKey(gridRoot(host), "ArrowDown");
await settle();
expect(displayRows(host)).toHaveLength(2);
});
it("does not add a second row when the down arrow is pressed again right after adding", async () => {
const { host } = mountGrid({ rowCount: 2 });
await settle();
await addRowWithArrowDown(host, 2);
expect(displayRows(host)).toHaveLength(3);
// 新增后编辑器已开在新行上:此时的下键属于编辑器,不得再追加一行
const event = dispatchKey(gridRoot(host), "ArrowDown");
await settle();
expect(displayRows(host)).toHaveLength(3);
expect(event.defaultPrevented).toBe(false);
});
// 行为级回归守卫(不是分支证明):快速录入开启时末尾是草稿行,此时下键不得凭空多出第二个空行。
// 草稿行本身不进 selectedCellCount,所以真正拦住它的是「单格选中」条件;这条用例锁定的是最终行为。
it("does not add a row while quick entry leaves a draft row at the end", async () => {
const { host } = mountGrid({ rowCount: 2, quickEntry: true });
await settle();
const rowsBefore = displayRows(host);
// 2 条已落库行 + 末尾那行快速录入草稿行
expect(rowsBefore.length).toBe(3);
await selectCellAt(host, rowsBefore.length - 1, 0);
dispatchKey(gridRoot(host), "ArrowDown");
await settle();
expect(displayRows(host)).toHaveLength(rowsBefore.length);
});
it("adds a row in canvas render mode too", async () => {
const { host, grid } = mountGrid({ rowCount: 2, renderMode: "canvas" });
await settle();
const root = gridRoot(host);
// Canvas 模式没有行元素,用「新增行立即进入编辑」这条与按钮一致的信号判定
expect(displayRows(host)).toHaveLength(0);
grid.value!.selectSingleCell(1, 0);
await settle();
const event = dispatchKey(root, "ArrowDown");
await settle();
expect(event.defaultPrevented).toBe(true);
expect(host.querySelector(".canvas-grid-overlay .cell-edit-input")).not.toBeNull();
});
});
describe("DataGrid escape pending row and cell revert", () => {
it("removes the untouched new row under the cursor and keeps it undoable", async () => {
const { host } = mountGrid({ rowCount: 2 });
await settle();
await addRowWithArrowDown(host, 2);
await closeOpenEditor(host);
expect(displayRows(host)).toHaveLength(3);
await selectCellAt(host, 2, 0);
const escape = dispatchKey(gridRoot(host), "Escape");
await settle();
expect(escape.defaultPrevented).toBe(true);
expect(displayRows(host)).toHaveLength(2);
dispatchKey(gridRoot(host), "z", { ctrlKey: true });
await settle();
expect(displayRows(host)).toHaveLength(3);
});
it("clears the cell instead of removing a new row that already has a value", async () => {
const { host } = mountGrid({ rowCount: 2 });
await settle();
await addRowWithArrowDown(host, 2);
// 新增后编辑器开在新行上:填一个值再提交,该行就不再是「未触碰」
await typeIntoOpenEditor(host, "typed");
await selectCellAt(host, 2, 0);
const escape = dispatchKey(gridRoot(host), "Escape");
await settle();
expect(escape.defaultPrevented).toBe(true);
expect(displayRows(host)).toHaveLength(3);
expect(cellsOf(displayRows(host)[2]!)[0]!.classList.contains("cell-dirty")).toBe(false);
expect(displayRows(host)[2]!.textContent).not.toContain("typed");
});
it("keeps the pending row when Escape only cancels the inline editor", async () => {
const { host } = mountGrid({ rowCount: 2 });
await settle();
await addRowWithArrowDown(host, 2);
const editor = host.querySelector<HTMLElement>(".cell-edit-input");
expect(editor).not.toBeNull();
editor!.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Escape" }));
await settle();
// 两段式:编辑器内的 Esc 只取消输入,新增行留在网格里(网格焦点下的第二次 Esc 才撤掉它)
expect(host.querySelector(".cell-edit-input")).toBeNull();
expect(displayRows(host)).toHaveLength(3);
await selectCellAt(host, 2, 0);
dispatchKey(gridRoot(host), "Escape");
await settle();
expect(displayRows(host)).toHaveLength(2);
});
it("restores a dirty existing cell to its original value", async () => {
const { host } = mountGrid({ rowCount: 2 });
await settle();
await selectCellAt(host, 0, 1);
dispatchKey(gridRoot(host), "Enter");
await settle();
await typeIntoOpenEditor(host, "renamed");
await selectCellAt(host, 0, 1);
expect(cellsOf(displayRows(host)[0]!)[1]!.classList.contains("cell-dirty")).toBe(true);
const escape = dispatchKey(gridRoot(host), "Escape");
await settle();
expect(escape.defaultPrevented).toBe(true);
expect(cellsOf(displayRows(host)[0]!)[1]!.classList.contains("cell-dirty")).toBe(false);
expect(displayRows(host)[0]!.textContent).toContain("name-0");
expect(displayRows(host)[0]!.textContent).not.toContain("renamed");
});
it("does not revert while a save is in flight, and reverts once it finishes", async () => {
const { host, grid } = mountGrid({ rowCount: 2 });
await settle();
await selectCellAt(host, 0, 1);
dispatchKey(gridRoot(host), "Enter");
await settle();
await typeIntoOpenEditor(host, "renamed");
await selectCellAt(host, 0, 1);
const editedCell = () => cellsOf(displayRows(host)[0]!)[1]!;
expect(editedCell().classList.contains("cell-dirty")).toBe(true);
// 保存进行中:这一轮保存已经捕获了当前值,此刻回退会与数据库写入结果脱节
grid.value!.isSaving = true;
const whileSaving = dispatchKey(gridRoot(host), "Escape");
await settle();
expect(whileSaving.defaultPrevented).toBe(false);
expect(editedCell().classList.contains("cell-dirty")).toBe(true);
expect(displayRows(host)[0]!.textContent).toContain("renamed");
// 正向对照:保存结束后同一个键位恢复回退能力(证明拦住它的是 isSaving 门槛本身)
grid.value!.isSaving = false;
const afterSaving = dispatchKey(gridRoot(host), "Escape");
await settle();
expect(afterSaving.defaultPrevented).toBe(true);
expect(editedCell().classList.contains("cell-dirty")).toBe(false);
expect(displayRows(host)[0]!.textContent).toContain("name-0");
});
it("does not consume Escape when there is nothing to revert", async () => {
const { host } = mountGrid({ rowCount: 2 });
await settle();
await selectCellAt(host, 0, 1);
const escape = dispatchKey(gridRoot(host), "Escape");
await settle();
expect(escape.defaultPrevented).toBe(false);
expect(displayRows(host)).toHaveLength(2);
expect(displayRows(host)[0]!.textContent).toContain("name-0");
});
it("does not revert a multi-cell selection", async () => {
const { host } = mountGrid({ rowCount: 2 });
await settle();
await selectCellAt(host, 0, 0);
await selectCellAt(host, 0, 1);
// 拖选出跨格范围:第一次选择作为锚点,第二次带 shiftKey 扩展
const rangeCell = cellsOf(displayRows(host)[1]!)[1]!;
rangeCell.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, shiftKey: true }));
window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, button: 0 }));
await settle();
const escape = dispatchKey(gridRoot(host), "Escape");
await settle();
expect(escape.defaultPrevented).toBe(false);
});
});
@@ -2384,3 +2384,52 @@ describe("Mongo collection-grid clipboard round-trip", () => {
}
});
});
describe("useDataGridEditor pending-row cancel and cell revert", () => {
it("reports whether restoreCellValue actually reverted something", () => {
const editor = createEditor(undefined, true, undefined, undefined, [["Ada", "original", "Lovelace"]]);
expect(editor.restoreCellValue(0, 1)).toBe(false);
editor.applyCellValue(0, 1, "edited");
expect(editor.restoreCellValue(0, 1)).toBe(true);
expect(editor.restoreCellValue(0, 1)).toBe(false);
editor.newRows.value = [];
editor.addRows(1);
// 未触碰的新行整行都是 NULL,没有可回退的内容
expect(editor.restoreCellValue(-1, 0)).toBe(false);
editor.applyCellValue(-1, 0, "typed");
expect(editor.restoreCellValue(-1, 0)).toBe(true);
});
it("cancels only untouched pending rows", () => {
const editor = createEditor(undefined, true, undefined, undefined, [["Ada", "original", "Lovelace"]]);
editor.newRows.value = [];
editor.addRows(2);
// 已落库的行不是「新增行」,取消新增不适用于它
expect(editor.cancelUntouchedPendingRow(0)).toBe(false);
expect(editor.cancelUntouchedPendingRow(-1)).toBe(true);
expect(editor.newRows.value).toHaveLength(1);
// 填写过再清空的行不再是「未触碰」,必须留在网格与保存预览里
editor.applyCellValue(-1, 0, "kept");
editor.restoreCellValue(-1, 0);
expect(editor.cancelUntouchedPendingRow(-1)).toBe(false);
expect(editor.newRows.value).toHaveLength(1);
});
it("keeps the cancelled pending row undoable", () => {
const editor = createEditor(undefined, true, undefined, undefined, [["Ada", "original", "Lovelace"]]);
editor.newRows.value = [];
editor.addRows(2);
expect(editor.cancelUntouchedPendingRow(-1)).toBe(true);
expect(editor.newRows.value).toHaveLength(1);
expect(editor.canUndoPendingChange.value).toBe(true);
editor.undoPendingChange();
expect(editor.newRows.value).toHaveLength(2);
expect(editor.newRowMeta.value[0]?.untouched).toBe(true);
});
});
@@ -1184,45 +1184,60 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
}
}
function restoreCellValue(rowId: number, col: number) {
if (!canEditColumn(col)) return;
// 返回「是否真的回退了内容」。网格焦点下的 Esc(#11634)只有在确有改动被回退时才消费按键。
function restoreCellValue(rowId: number, col: number): boolean {
if (!canEditColumn(col)) return false;
const item = getRowItem(rowId);
if (!item || item.isDeleted) return;
if (!item || item.isDeleted) return false;
if (item.isDraft) {
ensureQuickEntryDraftRow();
if (quickEntryDraftRow.value[col] === null) return;
if (quickEntryDraftRow.value[col] === null) return false;
pushUndoSnapshot();
const nextDraftRow = [...quickEntryDraftRow.value];
nextDraftRow[col] = null;
quickEntryDraftRow.value = draftRowHasValue(nextDraftRow) ? nextDraftRow : emptyDraftRow();
touchPendingChanges();
return;
return true;
}
if (item.isNew && item.newIndex !== undefined) {
if (isSavingNewRow(item)) return;
if (isSavingNewRow(item)) return false;
const row = newRows.value[item.newIndex];
if (!row || row[col] === null) return;
if (!row || row[col] === null) return false;
pushUndoSnapshot();
row[col] = null;
updateNewRowEditedColumns(item.newIndex, col, null);
newRows.value = [...newRows.value];
newRowMeta.value = [...newRowMeta.value];
touchPendingChanges();
return;
return true;
}
if (item.sourceIndex === undefined) return;
if (!canEditExistingRows.value) return;
if (item.sourceIndex === undefined) return false;
if (!canEditExistingRows.value) return false;
const rowChanges = dirtyRows.value.get(item.sourceIndex);
if (!rowChanges?.has(col)) return;
if (!rowChanges?.has(col)) return false;
pushUndoSnapshot();
rowChanges.delete(col);
if (rowChanges.size === 0) dirtyRows.value.delete(item.sourceIndex);
dirtyRows.value = new Map(dirtyRows.value);
touchPendingChanges();
onCellValueChanged?.(rowId, col);
return true;
}
// #11634:Esc 取消「刚新增且从未写入」的待插入行。这类行不进 INSERT 批次
// (snapshotPendingSaveChanges 会跳过 untouched 行),因此撤掉它等于整体取消这次新增。
// 「填写过再清空」的行在首次提交时就被 updateNewRowEditedColumns 摘掉了 untouched 标记,
// 不在取消之列——它们必须保留在保存预览里(useDataGridEditor.spec.ts 锁定了该语义)。
function cancelUntouchedPendingRow(rowId: number): boolean {
const item = getRowItem(rowId);
if (!item?.isNew || item.newIndex === undefined) return false;
if (isSavingNewRow(item) || !isUntouchedNewRow(item.data, item.newIndex)) return false;
// 走既有的未触碰行删除路径:免二次确认,且写入撤销栈
requestDeleteRows([rowId]);
return true;
}
function cancelEdit() {
@@ -1241,6 +1256,9 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
} else if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
// 只取消本次输入,绝不在这里顺手删掉刚新增的空行:新增行在网格里是「批量粘贴的目标」
// (batchAppendPaste 要求选中的是空白新增行),把「离开编辑器」和「取消新增」合并
// 会让习惯了 Esc 收尾的用户莫名丢掉那行。取消新增由网格焦点下的第二次 Esc 承担(#11634)。
cancelEdit();
}
}
@@ -2563,6 +2581,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
commitEditFromBlur,
applyCellValue,
restoreCellValue,
cancelUntouchedPendingRow,
cancelEdit,
onEditKeydown,
addRow,
@@ -170,14 +170,18 @@ export function resolveCanvasDataGridRowBase(
const stripedRows = options.stripedRows ?? options.zebraStriping ?? true;
const stripedRowBg = options.zebraRowBg?.trim() || theme.rowMuted;
if (item.isDeleted) return theme.rowDeleted;
if (item.isNew && !isActive) return theme.rowNew;
// 待插入行的整行底色优先于活动行底色(#11634):光标总在新行上(新增后立即进入编辑),
// 若让活动行底色盖掉它,「这行还没保存」就恰好在这时看不见了。
if (item.isNew) return theme.rowNew;
if (item.isDraft && !isActive) return theme.rowMuted;
if (stripedRows && item.displayIndex % 2 === 1 && !isActive) return stripedRowBg;
return theme.background;
}
export function resolveCanvasDataGridRowFill(theme: Pick<DataGridPaintTheme, "cellActive" | "cellSelected">, rowBase: string, options: { isActive: boolean; isDeleted: boolean; isSelected: boolean }): string {
export function resolveCanvasDataGridRowFill(theme: Pick<DataGridPaintTheme, "cellActive" | "cellSelected">, rowBase: string, options: { isActive: boolean; isDeleted: boolean; isSelected: boolean; isNew?: boolean }): string {
if (options.isSelected) return theme.cellSelected;
// 同上:待插入行保留自己的底色,活动行高亮不再覆盖它
if (options.isNew) return rowBase;
if (options.isActive && !options.isDeleted) return theme.cellActive;
return rowBase;
}
@@ -479,6 +483,7 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions): boolean
isActive: rowIsActive,
isDeleted: item.isDeleted,
isSelected: rowSelectionVisual,
isNew: item.isNew,
});
const rowBorderY = crispCanvasLine(y + CANVAS_DATA_GRID_ROW_HEIGHT - 1, scaleY);
ctx.globalAlpha = item.isDeleted ? 0.7 : 1;
@@ -61,6 +61,12 @@ export const DATA_GRID_DARK_CROSSHAIR_ROW_BG = "rgb(75, 84, 98)";
export const DATA_GRID_DARK_CROSSHAIR_COL_BG = "rgb(98, 111, 130)";
export const DATA_GRID_LIGHT_STRIPED_ROW_BG = "rgb(240, 240, 240)";
export const DATA_GRID_DARK_STRIPED_ROW_BG = "rgb(40, 40, 43)";
// 新增行(未落库的待插入行)整行底色,必须与普通行、斑马纹行「一眼可辨」(#11634):
// 用与行号列 --data-grid-row-number-new-bg 同族的 emerald,但强度更低(10% vs 15%),
// 使整行有绿调的同时,行号标记(填写后才出现)仍然比行底色更醒目。取值与网格组件的
// --data-grid-row-new-bg(含 color-mix 变体)保持一致。
export const DATA_GRID_LIGHT_NEW_ROW_BG = "rgb(231, 248, 242)";
export const DATA_GRID_DARK_NEW_ROW_BG = "rgb(19, 37, 33)";
export const DATA_GRID_DARK_ROW_NUMBER_BG = "rgb(35, 37, 42)";
const DATA_GRID_DARK_ROW_NUMBER_NEW_BG = "rgb(33, 45, 40)";
const DATA_GRID_DARK_ROW_NUMBER_EDITED_BG = "rgb(48, 41, 28)";
@@ -273,7 +279,7 @@ export function resolveDataGridPaintTheme(options: { getVar: (name: string) => s
const accent = cssVarColor(getVar, "--accent", isDark ? "rgb(46, 47, 51)" : "rgb(245, 245, 245)");
const activeSurface = dataGridActiveRowBackground(isDark);
const rowMuted = isDark ? DATA_GRID_DARK_STRIPED_ROW_BG : DATA_GRID_LIGHT_STRIPED_ROW_BG;
const rowNew = isDark ? "rgb(51, 51, 55)" : "rgb(243, 243, 243)";
const rowNew = isDark ? DATA_GRID_DARK_NEW_ROW_BG : DATA_GRID_LIGHT_NEW_ROW_BG;
const rowDeleted = isDark ? "rgb(55, 31, 32)" : "rgb(255, 244, 244)";
const cellActive = activeSurface;
// 浅/深都读 CSS 变量(--data-grid-cell-crosshair-*-bg 从 --primary 派生),保证 DOM 与
+6
View File
@@ -65,6 +65,12 @@ DBX 使用虚拟滚动,只渲染当前可视区域。常用浏览操作包括
日期时间、枚举、JSON 和其他类型会在可识别时使用专用编辑控件。批量编辑前应确认所选区域和目标字段类型一致。
## 键盘操作
- **下键到末尾再加一行**:光标停在当前显示的最后一行时,再按一次 `↓` 会新增一行并进入首列编辑;新行**固定追加到结果末尾**(与工具栏「新增行」按钮不同——按钮跟随「插入位置」设置)
- **`Esc` 取消新增行**:在从未填写过的新增行上按 `Esc`,该行会被撤掉(`Ctrl+Z` 可恢复);填写过再清空的行会保留
- **`Esc` 回退单元格**:编辑器打开时 `Esc` 只取消本次输入;焦点回到表格后再按 `Esc`,当前单元格的待保存改动回退成原值(对齐 DBeaver 的 Reset cell changes)。多格选区不适用
## SQL 预览与保存
行级关系型数据修改先保存在本地待提交状态。点击保存时,DBX 会展示生成的 `INSERT`、`UPDATE` 和 `DELETE` SQL。
+6
View File
@@ -65,6 +65,12 @@ Read-only cells can still open details, copy, and export. Check the data header
DBX uses specialized controls for recognizable date/time, enum, JSON, and related types. Before bulk editing, verify that the selected region matches the target column types.
## Keyboard
- **Add a row with the down arrow**: with the cursor on the last displayed row, pressing `↓` again adds a row and opens it for editing in its first column. The new row is always appended to the end of the result - unlike the Add Row button, which follows the toolbar insert position.
- **Cancel a new row with `Esc`**: pressing `Esc` on a new row that has never been filled in removes that row (`Ctrl+Z` restores it). A row that was filled in and then cleared is kept.
- **Revert a cell with `Esc`**: while a cell editor is open, `Esc` only cancels the current input; with the grid focused, `Esc` reverts the selected cell's pending change to its original value, matching DBeaver's *Reset cell changes*. Multi-cell selections are left alone.
## SQL Preview and Save
Row-level relational mutations remain local until saved. DBX then shows the generated `INSERT`, `UPDATE`, and `DELETE` statements.
@@ -46,6 +46,8 @@ DBX 的快捷键来自同一份可配置 action registry。**设置 → 快捷
「聚焦 WHERE 条件输入框」默认不绑定快捷键。可在 **设置 → 快捷键 → 数据表格** 中自行设置,按下已配置的快捷键后将光标移至当前表数据标签页的 WHERE 输入框,保留现有条件且不执行查询。WHERE 输入框不可用时不生效。
网格还有两个固定手势(不可自定义):光标停在当前显示的最后一行时按 `↓` 会在结果末尾追加一行(与「新增行」按钮不同,按钮跟随「插入位置」设置);编辑器内的 `Esc` 只取消本次输入,焦点回到网格后按 `Esc` 会撤掉未填写过的新增行,否则把当前单元格回退为原值。详见[数据表格](/cn/docs/data-grid)。
### 全局和导航
| 默认键 | 操作 |
+2
View File
@@ -46,6 +46,8 @@ Standard copy and paste still follow platform conventions. Copy Current Row, Del
**Focus WHERE condition** has no default shortcut. Assign your preferred binding in **Settings → Shortcuts → Data grid** to focus the WHERE input in the active table data tab. It keeps the current condition and does not execute a query. It does nothing when the WHERE input is unavailable.
The grid also has two fixed gestures that are not rebindable: pressing `↓` while the cursor is on the last displayed row appends a row at the end of the result (unlike Add Row, which follows the toolbar insert position), and `Esc` inside a cell editor only cancels the current input — with the grid focused, `Esc` removes a new row that was never filled in, otherwise it reverts the selected cell to its original value. See [Data grid](/en/docs/data-grid).
### Global and Navigation
| Default | Action |
+38
View File
@@ -3,6 +3,19 @@ import { test } from "vitest";
import { canvasDataGridActionReservedWidth, drawBooleanCheckbox, fitCanvasText, resolveCanvasCellTextLayout, resolveCanvasDataGridRowBase, resolveCanvasDataGridRowFill } from "../../apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts";
import { DATA_GRID_DARK_STRIPED_ROW_BG, DATA_GRID_LIGHT_STRIPED_ROW_BG, resolveDataGridPaintTheme } from "../../apps/desktop/src/lib/dataGrid/dataGridPaintTheme.ts";
function rgbChannels(color: string): number[] {
const match = color.match(/rgba?\(([^)]+)\)/);
assert.ok(match, `not an rgb colour: ${color}`);
return match[1]!.split(",").slice(0, 3).map((part) => Number.parseFloat(part.trim()));
}
/** 最大单通道差值:用于证明「一眼可辨」,而不是仅仅不相等。 */
function channelDistance(first: string, second: string): number {
const left = rgbChannels(first);
const right = rgbChannels(second);
return Math.max(...left.map((channel, index) => Math.abs(channel - right[index]!)));
}
function measureContext(charWidth = 1): CanvasRenderingContext2D {
return {
font: "13px sans-serif",
@@ -59,6 +72,31 @@ test("data grid paint themes use the increased striped row contrast", () => {
assert.equal(resolveDataGridPaintTheme({ getVar, isDark: true }).rowMuted, DATA_GRID_DARK_STRIPED_ROW_BG);
});
test("pending new rows are visually distinct from striped rows and the base surface in both themes", () => {
const getVar = () => "";
for (const isDark of [false, true]) {
const theme = resolveDataGridPaintTheme({ getVar, isDark });
// 19/20 世纪那种「243 vs 240」的差距在屏幕上不可辨:要求至少一个通道差 ≥ 8
assert.ok(channelDistance(theme.rowNew, theme.rowMuted) >= 8, `new vs striped (dark=${isDark}): ${theme.rowNew} / ${theme.rowMuted}`);
assert.ok(channelDistance(theme.rowNew, theme.background) >= 8, `new vs background (dark=${isDark}): ${theme.rowNew} / ${theme.background}`);
assert.ok(channelDistance(theme.rowMuted, theme.background) >= 8, `striped vs background (dark=${isDark}): ${theme.rowMuted} / ${theme.background}`);
}
});
test("pending new rows keep their own colour when they hold the active cursor", () => {
const theme = { background: "bg-base", rowMuted: "row-muted", rowNew: "row-new", rowDeleted: "row-deleted", cellActive: "active-blue", cellSelected: "selected-blue" };
const newRow = { displayIndex: 0, isDeleted: false, isNew: true, isDraft: false };
const baseRow = { displayIndex: 0, isDeleted: false, isNew: false, isDraft: false };
// 光标总在新行上(新增后立即进入编辑),活动行底色不得盖掉「这行还没保存」
assert.equal(resolveCanvasDataGridRowBase(theme, newRow, { isActive: true }), "row-new");
assert.equal(resolveCanvasDataGridRowFill(theme, "row-new", { isActive: true, isDeleted: false, isSelected: false, isNew: true }), "row-new");
// 对照:普通行的活动底色与选中底色保持不变
assert.equal(resolveCanvasDataGridRowFill(theme, "bg-base", { isActive: true, isDeleted: false, isSelected: false }), "active-blue");
assert.equal(resolveCanvasDataGridRowFill(theme, "row-new", { isActive: true, isDeleted: false, isSelected: true, isNew: true }), "selected-blue");
});
test("data grid paint theme uses the resolved striped row token", () => {
const getVar = (name: string) => (name === "--data-grid-row-muted-bg" ? "rgb(235, 239, 244)" : "");
+16 -1
View File
@@ -1,7 +1,11 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "vitest";
import { resolveDataGridPaintTheme } from "../../apps/desktop/src/lib/dataGrid/dataGridPaintTheme.ts";
import { DATA_GRID_DARK_NEW_ROW_BG, DATA_GRID_LIGHT_NEW_ROW_BG, resolveDataGridPaintTheme } from "../../apps/desktop/src/lib/dataGrid/dataGridPaintTheme.ts";
function escapeColor(color: string): string {
return color.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
test("DataGrid DOM surfaces use one opaque theme background", () => {
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
@@ -22,6 +26,17 @@ test("DataGrid DOM surfaces use one opaque theme background", () => {
assert.doesNotMatch(source, /(?:canvas-grid-scroller|data-grid-scroller)[^}]*background-color:\s*var\(--background\)/s);
});
test("DataGrid DOM paints pending new rows with the same colour source as the canvas", () => {
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
// DOM 行底色与 Canvas 的 theme.rowNew 必须来自同一个常量/变量,避免只改一侧而悄悄漂移
for (const color of [DATA_GRID_LIGHT_NEW_ROW_BG, DATA_GRID_DARK_NEW_ROW_BG]) {
assert.match(source, new RegExp(`--data-grid-row-new-bg:\\s*${escapeColor(color)};`));
}
// 现代浏览器走 color-mix 变体:与行号列的新行标记同族(emerald)但更淡
assert.match(source, /--data-grid-row-new-bg:\s*color-mix\(in oklab,\s*rgb\(16 185 129\) 10%,\s*var\(--background\)\);/);
});
test("DataGrid canvas background stays opaque when the wallpaper background has alpha", () => {
const getThemeBackground = (varBackground: string, solidBackground: string, dataGridBackground: string, isDark: boolean) => {
return resolveDataGridPaintTheme({