feat(structure-editor): support ctrl/shift multi-select for columns

This commit is contained in:
lizzzzz777 authored and GitHub committed 2026-08-24 13:08:36 +08:00
1 parent 4c938328b7
commit 80240d0ba6
11 files changed
+643 -350

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { isSyntheticContextMenuClick, resolveColumnSelectionActiveId, structureColumnSelectionRange } from "@/lib/table/tableStructureEditorState";
function columnsOf(...ids: string[]) {
return ids.map((id) => ({ id, markedForDrop: id.startsWith("drop:") }));
}
describe("structureColumnSelectionRange", () => {
it("returns the contiguous range between anchor and current row in visible order", () => {
const columns = columnsOf("a", "b", "c", "d");
expect(structureColumnSelectionRange(columns, "a", "c")).toEqual(["a", "b", "c"]);
});
it("supports a backwards shift-click (anchor below the current row)", () => {
const columns = columnsOf("a", "b", "c", "d");
expect(structureColumnSelectionRange(columns, "d", "b")).toEqual(["b", "c", "d"]);
});
it("drops rows marked for drop from the range but keeps the span boundaries", () => {
const columns = columnsOf("a", "drop:b", "c");
expect(structureColumnSelectionRange(columns, "a", "c")).toEqual(["a", "c"]);
});
it("falls back to the clicked row when the anchor no longer exists", () => {
const columns = columnsOf("a", "b");
expect(structureColumnSelectionRange(columns, "missing", "b")).toEqual(["b"]);
});
it("returns an empty range when the clicked row no longer exists", () => {
const columns = columnsOf("a", "drop:b");
expect(structureColumnSelectionRange(columns, "a", "missing")).toEqual([]);
});
it("keeps the span up to a drop-marked clicked row, excluding the row itself", () => {
// The component guards clicks on drop-marked rows before they reach this
// helper; a stale range still resolves like the object browser does.
const columns = columnsOf("a", "drop:b", "c");
expect(structureColumnSelectionRange(columns, "c", "drop:b")).toEqual(["c"]);
});
});
describe("structure column interaction state", () => {
it("moves the active row to the last remaining selected column after toggling one off", () => {
const columns = columnsOf("a", "b", "c");
expect(resolveColumnSelectionActiveId(columns, new Set(["a", "c"]), "b")).toBe("c");
expect(resolveColumnSelectionActiveId(columns, new Set(), "b")).toBeNull();
});
it("only suppresses the macOS Ctrl+right-click synthetic click", () => {
expect(isSyntheticContextMenuClick(2, true, 0)).toBe(true);
expect(isSyntheticContextMenuClick(2, false, 0)).toBe(false);
expect(isSyntheticContextMenuClick(0, true, 0)).toBe(false);
});
});
+2
View File
@@ -3855,6 +3855,8 @@ export default {
restore: "Restore",
remove: "Remove",
moveColumnUp: "Move column up",
copySelectedColumns: "Copy selected fields ({count})",
dropSelectedColumns: "Drop selected fields ({count})",
moveColumnDown: "Move column down",
dragColumn: "Drag to reorder column",
localColumnOrderNotice: "Column order is saved locally and does not change the database table structure.",
+2
View File
@@ -3670,6 +3670,8 @@ export default withEnglishFallback({
restore: "Restaurar",
remove: "Quitar",
moveColumnUp: "Mover columna arriba",
copySelectedColumns: "Copiar campos seleccionados ({count})",
dropSelectedColumns: "Eliminar campos seleccionados ({count})",
moveColumnDown: "Mover columna abajo",
dragColumn: "Arrastra para reordenar columnas",
localColumnOrderNotice: "El orden de las columnas se guarda solo localmente y no modifica la estructura de la base de datos.",
+2
View File
@@ -3668,6 +3668,8 @@ export default withEnglishFallback({
restore: "Ripristina",
remove: "Rimuovi",
moveColumnUp: "Sposta colonna su",
copySelectedColumns: "Copia campi selezionati ({count})",
dropSelectedColumns: "Elimina campi selezionati ({count})",
moveColumnDown: "Sposta colonna giù",
dragColumn: "Trascina per riordinare la colonna",
localColumnOrderNotice: "L'ordine delle colonne viene salvato solo localmente e non modifica la struttura del database.",
+2
View File
@@ -3730,6 +3730,8 @@ export default withEnglishFallback({
restore: "復元",
remove: "削除",
moveColumnUp: "列を上に移動",
copySelectedColumns: "選択したフィールドをコピー ({count})",
dropSelectedColumns: "選択したフィールドを削除 ({count})",
moveColumnDown: "列を下に移動",
dragColumn: "ドラッグして列の順序を変更",
localColumnOrderNotice: "列の順序はローカルにのみ保存され、データベースのテーブル構造は変更されません。",
+2
View File
@@ -3624,6 +3624,8 @@ export default withEnglishFallback({
restore: "복원",
remove: "제거",
moveColumnUp: "컬럼을 위로 이동",
copySelectedColumns: "선택한 필드 복사 ({count})",
dropSelectedColumns: "선택한 필드 삭제 ({count})",
moveColumnDown: "컬럼을 아래로 이동",
dragColumn: "드래그하여 컬럼 순서 변경",
localColumnOrderNotice: "컬럼 순서는 로컬에만 저장되며 데이터베이스 테이블 구조를 변경하지 않습니다.",
+2
View File
@@ -3670,6 +3670,8 @@ export default withEnglishFallback({
restore: "Restaurar",
remove: "Remover",
moveColumnUp: "Mover coluna para cima",
copySelectedColumns: "Copiar campos selecionados ({count})",
dropSelectedColumns: "Excluir campos selecionados ({count})",
moveColumnDown: "Mover coluna para baixo",
dragColumn: "Arraste para reordenar colunas",
localColumnOrderNotice: "A ordem das colunas é salva apenas localmente e não altera a estrutura do banco de dados.",
+2
View File
@@ -3840,6 +3840,8 @@ export default withEnglishFallback({
restore: "恢复",
remove: "移除",
moveColumnUp: "上移字段",
copySelectedColumns: "复制选中的 {count} 个字段",
dropSelectedColumns: "删除选中的 {count} 个字段",
moveColumnDown: "下移字段",
dragColumn: "拖拽调整字段顺序",
localColumnOrderNotice: "字段顺序仅保存在本地,不会修改数据库表结构。",
+2
View File
@@ -3410,6 +3410,8 @@ export default withEnglishFallback({
restore: "復原",
remove: "移除",
moveColumnUp: "上移欄位",
copySelectedColumns: "複製選中的 {count} 個欄位",
dropSelectedColumns: "刪除選中的 {count} 個欄位",
moveColumnDown: "下移欄位",
dragColumn: "拖曳調整欄位順序",
localColumnOrderNotice: "欄位順序僅儲存在本機,不會修改資料庫資料表結構。",
@@ -5,6 +5,19 @@ export function hasExistingColumnTypeChange(columns: readonly EditableStructureC
return columns.some((column) => !!column.original && !column.markedForDrop && column.dataType !== column.original.data_type);
}
export function resolveColumnSelectionActiveId(columns: readonly Pick<EditableStructureColumn, "id" | "markedForDrop">[], selectedIds: ReadonlySet<string>, preferredId: string): string | null {
if (selectedIds.has(preferredId)) return preferredId;
for (let index = columns.length - 1; index >= 0; index -= 1) {
const column = columns[index];
if (column && !column.markedForDrop && selectedIds.has(column.id)) return column.id;
}
return null;
}
export function isSyntheticContextMenuClick(contextMenuButton: number | null, contextMenuCtrlKey: boolean, clickButton: number): boolean {
return contextMenuButton === 2 && contextMenuCtrlKey && clickButton === 0;
}
type TableStructureIdentifierCaseInfo = Pick<DatabaseConnectionInfo, "unquotedIdentifierCase" | "quotedIdentifierCase">;
const LOWER_UNQUOTED_MIXED_QUOTED_DATABASES = new Set<DatabaseType>(["postgres", "redshift", "opengauss", "gaussdb", "highgo", "uxdb"]);
@@ -1383,6 +1396,27 @@ export function resolveInsertColumnIndex(columns: readonly { id: string; markedF
return index >= 0 ? index + 1 : columns.length;
}
/**
* Compute the contiguous column-id range for a shift-click in the structure
* editor, mirroring the object browser's range-select behavior
* (objectBrowserSelection.ts): the range spans every row between the anchor
* and the clicked row in visible order, but rows marked for drop are not
* selectable and are dropped from the result.
*/
export function structureColumnSelectionRange(columns: readonly { id: string; markedForDrop?: boolean }[], anchorId: string, currentId: string): string[] {
const anchorIndex = columns.findIndex((column) => column.id === anchorId);
const currentIndex = columns.findIndex((column) => column.id === currentId);
if (anchorIndex < 0 || currentIndex < 0) {
return columns.some((column) => column.id === currentId && !column.markedForDrop) ? [currentId] : [];
}
const start = Math.min(anchorIndex, currentIndex);
const end = Math.max(anchorIndex, currentIndex);
return columns
.slice(start, end + 1)
.filter((column) => !column.markedForDrop)
.map((column) => column.id);
}
function isMysqlDeprecatedDefaultParameterType(baseType: string): boolean {
const typeName = baseType.split(/\s+/)[0];
return ["tinyint", "smallint", "mediumint", "int", "integer", "bigint", "float", "double", "real"].includes(typeName ?? "");