mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(grid): add row on arrow down and revert with escape
This commit is contained in:
@@ -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));
|
||||
|
||||
+448
@@ -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 与
|
||||
|
||||
@@ -65,6 +65,12 @@ DBX 使用虚拟滚动,只渲染当前可视区域。常用浏览操作包括
|
||||
|
||||
日期时间、枚举、JSON 和其他类型会在可识别时使用专用编辑控件。批量编辑前应确认所选区域和目标字段类型一致。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- **下键到末尾再加一行**:光标停在当前显示的最后一行时,再按一次 `↓` 会新增一行并进入首列编辑;新行**固定追加到结果末尾**(与工具栏「新增行」按钮不同——按钮跟随「插入位置」设置)
|
||||
- **`Esc` 取消新增行**:在从未填写过的新增行上按 `Esc`,该行会被撤掉(`Ctrl+Z` 可恢复);填写过再清空的行会保留
|
||||
- **`Esc` 回退单元格**:编辑器打开时 `Esc` 只取消本次输入;焦点回到表格后再按 `Esc`,当前单元格的待保存改动回退成原值(对齐 DBeaver 的 Reset cell changes)。多格选区不适用
|
||||
|
||||
## SQL 预览与保存
|
||||
|
||||
行级关系型数据修改先保存在本地待提交状态。点击保存时,DBX 会展示生成的 `INSERT`、`UPDATE` 和 `DELETE` SQL。
|
||||
|
||||
@@ -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)。
|
||||
|
||||
### 全局和导航
|
||||
|
||||
| 默认键 | 操作 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user