feat(editor): drag result grid columns into the SQL editor

This commit is contained in:
Yang Fei
2026-08-27 17:16:57 +08:00
committed by GitHub
parent 9e4fe8ce56
commit ab4e747bf9
17 changed files with 911 additions and 25 deletions
@@ -97,14 +97,18 @@ import { analyzeMysqlRoutineSyntax, supportsMysqlRoutineSyntaxDiagnostics } from
import {
DBX_TABLE_REFERENCE_MIME,
DBX_TABLE_REFERENCE_DROP_EVENT,
DBX_TABLE_REFERENCE_HOVER_EVENT,
DBX_TABLE_REFERENCE_DRAG_END_EVENT,
activeTableReferencePayloadValue,
clearActiveTableReferencePayload,
hasTableReferencePayloadType,
parseTableReferencePayload,
tableReferenceInsertText,
type QueryEditorTableReferenceDropDetail,
type QueryEditorTableReferenceHoverDetail,
type QueryEditorTableReferencePayload,
} from "@/lib/editor/queryEditorTableDrop";
import { isPointOverElementRoot } from "@/lib/editor/tableReferenceDragFeedback";
import type { SqlHighlighter } from "@/lib/sql/sqlHighlighter";
import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, editorDiagnosticColors, editorThemeAppearanceFor, loadEditorTheme, editorFontTheme, shellLineCommentTheme, sqlCompletionTheme, sqlSemanticHighlightTheme } from "@/lib/editor/editorThemes";
import { createStatementGutterMarkerDom, shouldShowStatementGutter } from "@/lib/editor/codemirrorStatementGutter";
@@ -3295,6 +3299,7 @@ function insertTableReferencePayload(currentView: EditorViewType, payload: Query
userEvent: "input.drop",
});
clearActiveTableReferencePayload(payload);
hideQueryEditorDropCaret();
currentView.focus();
return true;
}
@@ -3316,21 +3321,77 @@ function onTableReferenceDropEvent(event: Event) {
if (!currentView || props.readOnly || !(event instanceof CustomEvent)) return;
const detail = event.detail as QueryEditorTableReferenceDropDetail | undefined;
if (!detail?.payload) return;
const target = document.elementFromPoint(detail.clientX, detail.clientY);
if (target instanceof Element && editorRef.value?.contains(target)) {
// elementFromPoint 被透明覆盖层拦截时回退为编辑器根节点包围盒判定(见 isPointOverElementRoot)。
if (isPointOverElementRoot(detail.clientX, detail.clientY, editorRef.value)) {
insertTableReferencePayload(currentView, detail.payload, detail);
}
}
// --- 表引用拖拽悬停时的插入光标线(指针模拟拖拽经 window 事件驱动) ---
const queryEditorDropCaret = ref<{ left: number; top: number; height: number } | null>(null);
const queryEditorDropCaretStyle = computed(() => {
const caret = queryEditorDropCaret.value;
return caret ? { left: `${caret.left}px`, top: `${caret.top}px`, height: `${caret.height}px` } : {};
});
function showQueryEditorDropCaretAt(clientX: number, clientY: number) {
const currentView = view.value;
if (!currentView || props.readOnly || !editorRef.value) {
hideQueryEditorDropCaret();
return;
}
let dropPos: number | null = null;
try {
dropPos = currentView.posAtCoords({ x: clientX, y: clientY });
} catch {
dropPos = null;
}
if (dropPos == null) {
hideQueryEditorDropCaret();
return;
}
const coords = currentView.coordsAtPos(dropPos);
if (!coords) {
hideQueryEditorDropCaret();
return;
}
const rect = editorRef.value.getBoundingClientRect();
queryEditorDropCaret.value = { left: coords.left - rect.left, top: coords.top - rect.top, height: Math.max(coords.bottom - coords.top, 0) };
}
function hideQueryEditorDropCaret() {
queryEditorDropCaret.value = null;
}
function onTableReferenceHoverEvent(event: Event) {
if (!(event instanceof CustomEvent)) return;
const detail = event.detail as QueryEditorTableReferenceHoverDetail | undefined;
if (!detail) return;
// elementFromPoint 被透明覆盖层拦截时回退为编辑器根节点包围盒判定(见 isPointOverElementRoot)。
if (!isPointOverElementRoot(detail.clientX, detail.clientY, editorRef.value)) {
hideQueryEditorDropCaret();
return;
}
showQueryEditorDropCaretAt(detail.clientX, detail.clientY);
}
function onTableReferenceDragEndEvent() {
hideQueryEditorDropCaret();
}
function registerTableReferenceDropListener() {
if (tableReferenceDropListenerRegistered) return;
window.addEventListener(DBX_TABLE_REFERENCE_DROP_EVENT, onTableReferenceDropEvent);
window.addEventListener(DBX_TABLE_REFERENCE_HOVER_EVENT, onTableReferenceHoverEvent);
window.addEventListener(DBX_TABLE_REFERENCE_DRAG_END_EVENT, onTableReferenceDragEndEvent);
tableReferenceDropListenerRegistered = true;
}
function unregisterTableReferenceDropListener() {
if (!tableReferenceDropListenerRegistered) return;
window.removeEventListener(DBX_TABLE_REFERENCE_DROP_EVENT, onTableReferenceDropEvent);
window.removeEventListener(DBX_TABLE_REFERENCE_HOVER_EVENT, onTableReferenceHoverEvent);
window.removeEventListener(DBX_TABLE_REFERENCE_DRAG_END_EVENT, onTableReferenceDragEndEvent);
tableReferenceDropListenerRegistered = false;
}
@@ -5374,12 +5435,21 @@ onMounted(async () => {
return recoverLargeTauriPaste(event, currentView);
},
dragover(event) {
if (props.readOnly || !hasDroppedTableReference(event)) return false;
if (props.readOnly || !hasDroppedTableReference(event)) {
hideQueryEditorDropCaret();
return false;
}
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
showQueryEditorDropCaretAt(event.clientX, event.clientY);
return true;
},
dragleave() {
hideQueryEditorDropCaret();
return false;
},
drop(event, currentView) {
hideQueryEditorDropCaret();
return insertDroppedTableReference(currentView, event);
},
blur(_event, currentView) {
@@ -6187,6 +6257,7 @@ defineExpose({
"
/>
</CustomContextMenu>
<div v-show="queryEditorDropCaret" data-query-editor-drop-caret class="pointer-events-none absolute z-20 w-0.5 rounded-full bg-primary/70" :style="queryEditorDropCaretStyle" />
<EditorSearchPanel ref="searchPanelRef" :view="view" />
<SqlExecutionTargetPicker v-if="pickerVisible" :candidates="pickerCandidates" :active-index="pickerActiveIndex" :anchor="pickerAnchor" @update:active-index="onPickerActiveIndexChange" @confirm="onPickerConfirm" @cancel="closePicker" />
<DelimitedListDialog v-model:open="delimitedListOpen" :selected-text="delimitedListSelectedText" @confirm="applyDelimitedListResult" />
+60 -1
View File
@@ -303,7 +303,9 @@ import { DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS, DATA_GRID_COPY_EXTRACTOR_IDS, DAT
import { columnNamesForCopy } from "@/lib/dataGrid/dataGridColumnNameCopy";
import { DATA_GRID_ROW_NUM_WIDTH, dataGridRowNumberColumnWidth, resolveDataGridMaxRowNumber, useDataGridColumnResize } from "@/composables/useDataGridColumnResize";
import { createDataGridColumnStructureSignature } from "@/lib/dataGrid/dataGridColumnWidthState";
import { useDataGridColumnLayout, useDataGridColumnLayoutState } from "@/composables/useDataGridColumnLayout";
import { useDataGridColumnLayout, useDataGridColumnLayoutState, type ColumnHeaderReferenceDragController } from "@/composables/useDataGridColumnLayout";
import { createColumnReferencePayload, createTableReferenceDragEndEvent, createTableReferenceDropEvent, createTableReferenceHoverEvent } from "@/lib/editor/queryEditorTableDrop";
import { beginTableReferenceDragFeedback, isOverSqlEditorTarget, type TableReferenceDragFeedback } from "@/lib/editor/tableReferenceDragFeedback";
import { dataGridCanvasDevicePixelSize, useDataGridCanvasRuntime, type DataGridCanvasRuntime } from "@/composables/useDataGridCanvasRuntime";
import { useDataGridScrollbars, type DataGridScrollbarsRuntime } from "@/composables/useDataGridScrollbars";
import { useDataGridSelection } from "@/composables/useDataGridSelection";
@@ -2867,6 +2869,62 @@ function unfreezeAllColumns() {
applyColumnOrderChange(unfreezeAllColumnsInLayout);
}
// --- 表头拖拽进 SQL 编辑器:目标导向模式切换的控制器 ---
// 按选择顺序(Set 插入序)取列名;被拖列未选中时仅拖该列。
function columnReferenceDragNames(draggedVisibleColIdx: number): string[] | null {
const selected = [...selection.selectedColumnIndexes.value];
const ordered = selected.includes(draggedVisibleColIdx) ? selected : [draggedVisibleColIdx];
const names = ordered.map((index) => visibleColumns.value[index]).filter((name): name is string => !!name);
return names.length > 0 ? names : null;
}
function buildColumnReferencePayload(draggedVisibleColIdx: number) {
const columnNames = columnReferenceDragNames(draggedVisibleColIdx);
if (!columnNames) return null;
return createColumnReferencePayload({
connectionId: props.connectionId,
database: props.database,
schema: props.schema,
columnNames,
databaseType: resolvedDatabaseType.value,
});
}
let referenceDragFeedback: TableReferenceDragFeedback | null = null;
// 拖拽 chip 文案:≤3 个列名直接平铺,更多时走 i18n 摘要模板。
function columnReferenceDragLabel(names: string[]): string {
if (names.length <= 3) return names.join(", ");
return t("grid.columnDragChipMany", { names: names.slice(0, 2).join(", "), count: names.length });
}
const columnHeaderReferenceDragController: ColumnHeaderReferenceDragController = {
isOverEditorTarget: (clientX, clientY) => isOverSqlEditorTarget(clientX, clientY),
onEnter(sourceVisibleIndex) {
const names = columnReferenceDragNames(sourceVisibleIndex);
if (!names) return null;
const label = columnReferenceDragLabel(names);
referenceDragFeedback?.end();
referenceDragFeedback = beginTableReferenceDragFeedback(label);
return label;
},
onMove(_sourceVisibleIndex, clientX, clientY) {
referenceDragFeedback?.update(clientX, clientY);
window.dispatchEvent(createTableReferenceHoverEvent({ clientX, clientY }));
},
onDrop(sourceVisibleIndex, clientX, clientY) {
const payload = buildColumnReferencePayload(sourceVisibleIndex);
if (!payload) return false;
window.dispatchEvent(createTableReferenceDropEvent({ payload, clientX, clientY }));
return true;
},
onCancel() {
referenceDragFeedback?.end();
referenceDragFeedback = null;
window.dispatchEvent(createTableReferenceDragEndEvent());
},
};
const {
renderedColumnOffsets,
horizontalColumnWindow,
@@ -2905,6 +2963,7 @@ const {
onRefreshMetrics: scheduleColumnLayoutRefresh,
onPersistColumnOrder: persistDraggedColumnOrder,
frozenColumnCount,
columnReferenceDrag: columnHeaderReferenceDragController,
});
function onHeaderClickCapture(event: MouseEvent) {
@@ -53,8 +53,9 @@ import { Switch } from "@/components/ui/switch";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import type { ColumnInfo, ConnectionConfig, CustomTypeTreeMemberMeta, DatabaseType, TreeNode, TriggerInfo } from "@/types/database";
import { alignedCommentLeadingWidth, canTreeNodePin, canTreeNodeShowExpander, sidebarTreeNodeComment, trailingCommentAvailableWidth, trailingCommentGapPx, treeItemPaddingLeft, treeLabelWidthClass, usesFullWidthTreeLabel } from "@/lib/sidebar/sidebarTreeItemLayout";
import { clearActiveTableReferencePayload, createTableReferencePayload, createTableReferenceDropEvent, setActiveTableReferencePayload, type QueryEditorTableReferencePayload } from "@/lib/editor/queryEditorTableDrop";
import { clearActiveTableReferencePayload, createTableReferenceDragEndEvent, createTableReferenceDropEvent, createTableReferenceHoverEvent, createTableReferencePayload, setActiveTableReferencePayload, type QueryEditorTableReferencePayload } from "@/lib/editor/queryEditorTableDrop";
import { AI_ASSISTANT_TABLE_DROP_ROOT_SELECTOR } from "@/lib/ai/aiTableReferenceDrop";
import { beginTableReferenceDragFeedback, isOverSqlEditorTarget, type TableReferenceDragFeedback } from "@/lib/editor/tableReferenceDragFeedback";
import { formatSidebarObjectStorage } from "@/lib/sidebar/sidebarDatabaseStorage";
import { dataTabOpenModeFromTreeClick } from "@/lib/sidebar/dataTabOpenPolicy";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
@@ -1126,8 +1127,6 @@ function clearTreeDragTarget() {
const TABLE_REFERENCE_DRAG_THRESHOLD = 5;
const TABLE_REFERENCE_DRAGGING_CLASS = "dbx-table-reference-dragging";
const canDragTableReference = computed(() => {
if (props.referenceDragDisabled || !activeNode.value.connectionId) return false;
if (activeNode.value.type === "database") return typeof activeNode.value.database === "string" && activeNode.value.database.trim().length > 0;
@@ -1144,8 +1143,15 @@ let pendingTableReferenceDrag: {
let draggingTableReferencePayload: QueryEditorTableReferencePayload | null = null;
let referenceDragFeedback: TableReferenceDragFeedback | null = null;
let suppressNextTableReferenceClick = false;
function tableReferenceDragLabel(payload: QueryEditorTableReferencePayload): string {
if (payload.referenceType === "column" && payload.columnName) return payload.columnName;
return payload.tableName || payload.database;
}
function tableReferenceDragPayload(): QueryEditorTableReferencePayload | null {
if (!canDragTableReference.value) return null;
if (activeNode.value.type === "database") {
@@ -1191,15 +1197,16 @@ function startTableReferenceDrag(payload: QueryEditorTableReferencePayload) {
draggingTableReferencePayload = payload;
setActiveTableReferencePayload(payload);
document.getSelection()?.removeAllRanges();
document.body.style.cursor = "copy";
referenceDragFeedback = beginTableReferenceDragFeedback(tableReferenceDragLabel(payload));
}
function finishTableReferenceDrag() {
clearActiveTableReferencePayload(draggingTableReferencePayload);
pendingTableReferenceDrag = null;
draggingTableReferencePayload = null;
document.body.classList.remove(TABLE_REFERENCE_DRAGGING_CLASS);
document.body.style.cursor = "";
referenceDragFeedback?.end();
referenceDragFeedback = null;
window.dispatchEvent(createTableReferenceDragEndEvent());
document.removeEventListener("mousemove", onTableReferenceMouseMove, true);
document.removeEventListener("mouseup", onTableReferenceMouseUp, true);
}
@@ -1215,6 +1222,11 @@ function onTableReferenceMouseMove(event: MouseEvent) {
if (draggingTableReferencePayload) {
event.preventDefault();
document.getSelection()?.removeAllRanges();
referenceDragFeedback?.update(event.clientX, event.clientY);
// 仅查询编辑器消费 hover 光标线事件;AI 面板不监听。命中判定含覆盖层拦截时的几何回退。
if (isOverSqlEditorTarget(event.clientX, event.clientY)) {
window.dispatchEvent(createTableReferenceHoverEvent({ clientX: event.clientX, clientY: event.clientY }));
}
}
}
@@ -1242,7 +1254,6 @@ function startTableReferenceMouseDrag(event: MouseEvent) {
if (!payload) return;
event.preventDefault();
document.getSelection()?.removeAllRanges();
document.body.classList.add(TABLE_REFERENCE_DRAGGING_CLASS);
pendingTableReferenceDrag = { payload, startX: event.clientX, startY: event.clientY };
document.addEventListener("mousemove", onTableReferenceMouseMove, true);
document.addEventListener("mouseup", onTableReferenceMouseUp, true);
@@ -2,7 +2,7 @@
import { effectScope, nextTick, ref } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { dataGridColumnOffsets, dataGridHorizontalColumnWindow, useDataGridColumnLayout, useDataGridColumnLayoutState } from "@/composables/useDataGridColumnLayout";
import { dataGridColumnOffsets, dataGridHorizontalColumnWindow, useDataGridColumnLayout, useDataGridColumnLayoutState, type ColumnHeaderReferenceDragController } from "@/composables/useDataGridColumnLayout";
import { columnHeaderDragAutoScrollDelta, columnHeaderDropTargetIndex } from "@/lib/dataGrid/dataGridColumnHeaderInteraction";
import { loadDataGridColumnLayout, saveDataGridColumnLayout } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
@@ -548,6 +548,280 @@ describe("useDataGridColumnLayout", () => {
scope.stop();
});
it("switches to reference mode over the editor and drops without reordering", () => {
vi.stubGlobal(
"requestAnimationFrame",
vi.fn((callback: FrameRequestCallback) => {
callback(0);
return 0;
}),
);
vi.stubGlobal("cancelAnimationFrame", vi.fn());
const header = document.createElement("div");
for (let index = 0; index < 2; index++) {
const column = document.createElement("div");
column.dataset.visibleColIndex = String(index);
column.getBoundingClientRect = () => ({ left: index * 100, width: 100, right: (index + 1) * 100, top: 0, bottom: 20, height: 20, x: index * 100, y: 0, toJSON: () => ({}) });
header.append(column);
}
const controller: ColumnHeaderReferenceDragController = {
isOverEditorTarget: (clientX) => clientX > 300,
onEnter: vi.fn(() => "id"),
onMove: vi.fn(),
onDrop: vi.fn(() => true),
onCancel: vi.fn(),
};
const persist = vi.fn();
const scope = effectScope();
const layout = scope.run(() =>
useDataGridColumnLayout({
columnNames: ref(["id", "name"]),
visibleColumnIndexes: ref([0, 1]),
renderedColumnWidths: ref([100, 100]),
scrollLeft: ref(0),
viewportWidth: ref(400),
rowNumberWidth: 40,
headerRef: ref(header),
onPersistColumnOrder: persist,
columnReferenceDrag: controller,
}),
)!;
layout.startColumnHeaderDrag(0, new PointerEvent("pointerdown", { button: 0, clientX: 50, clientY: 10 }));
// 网格内:仍是重排序预览
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 120, clientY: 10 }));
expect(controller.onEnter).not.toHaveBeenCalled();
expect(document.body.querySelector("[data-column-header-drag-preview]")).not.toBeNull();
// 进入编辑器区域:切换为引用模式,重排序预览移除
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 320, clientY: 10 }));
expect(controller.onEnter).toHaveBeenCalledWith(0);
expect(controller.onMove).toHaveBeenCalledWith(0, 320, 10);
expect(document.body.querySelector("[data-column-header-drag-preview]")).toBeNull();
expect(layout.columnHeaderPreviewOffsets.value).toEqual([0, 0]);
// 在编辑器内释放:插入但不重排列
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 320, clientY: 10 }));
expect(controller.onDrop).toHaveBeenCalledWith(0, 320, 10);
expect(controller.onCancel).toHaveBeenCalled();
expect(persist).not.toHaveBeenCalled();
scope.stop();
});
it("cancels the reference drag and restores reorder preview when leaving the editor", () => {
vi.stubGlobal(
"requestAnimationFrame",
vi.fn((callback: FrameRequestCallback) => {
callback(0);
return 0;
}),
);
vi.stubGlobal("cancelAnimationFrame", vi.fn());
const header = document.createElement("div");
for (let index = 0; index < 2; index++) {
const column = document.createElement("div");
column.dataset.visibleColIndex = String(index);
column.getBoundingClientRect = () => ({ left: index * 100, width: 100, right: (index + 1) * 100, top: 0, bottom: 20, height: 20, x: index * 100, y: 0, toJSON: () => ({}) });
header.append(column);
}
const controller: ColumnHeaderReferenceDragController = {
isOverEditorTarget: (clientX) => clientX > 300,
onEnter: vi.fn(() => "id"),
onMove: vi.fn(),
onDrop: vi.fn(() => true),
onCancel: vi.fn(),
};
const persist = vi.fn();
const scope = effectScope();
const layout = scope.run(() =>
useDataGridColumnLayout({
columnNames: ref(["id", "name"]),
visibleColumnIndexes: ref([0, 1]),
renderedColumnWidths: ref([100, 100]),
scrollLeft: ref(0),
viewportWidth: ref(400),
rowNumberWidth: 40,
headerRef: ref(header),
onPersistColumnOrder: persist,
columnReferenceDrag: controller,
}),
)!;
layout.startColumnHeaderDrag(0, new PointerEvent("pointerdown", { button: 0, clientX: 50, clientY: 10 }));
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 320, clientY: 10 }));
expect(controller.onEnter).toHaveBeenCalledTimes(1);
// 拖回网格:还原重排序预览,引用反馈被取消
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 120, clientY: 10 }));
expect(controller.onCancel).toHaveBeenCalledTimes(1);
expect(document.body.querySelector("[data-column-header-drag-preview]")).not.toBeNull();
// 拖出后释放(不在编辑器内):整体取消,不重排也不插入
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 320, clientY: 10 }));
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 120, clientY: 10 }));
expect(controller.onDrop).not.toHaveBeenCalled();
expect(persist).not.toHaveBeenCalled();
scope.stop();
});
it("keeps reorder mode when the controller refuses the reference drag", () => {
vi.stubGlobal(
"requestAnimationFrame",
vi.fn((callback: FrameRequestCallback) => {
callback(0);
return 0;
}),
);
vi.stubGlobal("cancelAnimationFrame", vi.fn());
const header = document.createElement("div");
for (let index = 0; index < 2; index++) {
const column = document.createElement("div");
column.dataset.visibleColIndex = String(index);
column.getBoundingClientRect = () => ({ left: index * 100, width: 100, right: (index + 1) * 100, top: 0, bottom: 20, height: 20, x: index * 100, y: 0, toJSON: () => ({}) });
header.append(column);
}
const controller: ColumnHeaderReferenceDragController = {
isOverEditorTarget: () => true,
onEnter: vi.fn(() => null),
onMove: vi.fn(),
onDrop: vi.fn(() => true),
onCancel: vi.fn(),
};
const persist = vi.fn();
const scope = effectScope();
const layout = scope.run(() =>
useDataGridColumnLayout({
columnNames: ref(["id", "name"]),
visibleColumnIndexes: ref([0, 1]),
renderedColumnWidths: ref([100, 100]),
scrollLeft: ref(0),
viewportWidth: ref(400),
rowNumberWidth: 40,
headerRef: ref(header),
onPersistColumnOrder: persist,
columnReferenceDrag: controller,
}),
)!;
layout.startColumnHeaderDrag(0, new PointerEvent("pointerdown", { button: 0, clientX: 50, clientY: 10 }));
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 320, clientY: 10 }));
expect(controller.onEnter).toHaveBeenCalled();
expect(controller.onMove).not.toHaveBeenCalled();
expect(document.body.querySelector("[data-column-header-drag-preview]")).not.toBeNull();
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 320, clientY: 10 }));
expect(controller.onDrop).not.toHaveBeenCalled();
expect(persist).toHaveBeenCalled();
scope.stop();
});
it("cancels a plain reorder drag released outside both the grid and the editor", () => {
vi.stubGlobal(
"requestAnimationFrame",
vi.fn((callback: FrameRequestCallback) => {
callback(0);
return 0;
}),
);
vi.stubGlobal("cancelAnimationFrame", vi.fn());
const header = document.createElement("div");
header.getBoundingClientRect = () => ({ left: 0, width: 200, right: 200, top: 0, bottom: 20, height: 20, x: 0, y: 0, toJSON: () => ({}) });
for (let index = 0; index < 2; index++) {
const column = document.createElement("div");
column.dataset.visibleColIndex = String(index);
column.getBoundingClientRect = () => ({ left: index * 100, width: 100, right: (index + 1) * 100, top: 0, bottom: 20, height: 20, x: index * 100, y: 0, toJSON: () => ({}) });
header.append(column);
}
const controller: ColumnHeaderReferenceDragController = {
isOverEditorTarget: () => false,
onEnter: vi.fn(() => null),
onMove: vi.fn(),
onDrop: vi.fn(() => true),
onCancel: vi.fn(),
};
const persist = vi.fn();
const scope = effectScope();
const layout = scope.run(() =>
useDataGridColumnLayout({
columnNames: ref(["id", "name"]),
visibleColumnIndexes: ref([0, 1]),
renderedColumnWidths: ref([100, 100]),
scrollLeft: ref(0),
viewportWidth: ref(400),
rowNumberWidth: 40,
headerRef: ref(header),
onPersistColumnOrder: persist,
columnReferenceDrag: controller,
}),
)!;
layout.startColumnHeaderDrag(0, new PointerEvent("pointerdown", { button: 0, clientX: 50, clientY: 10 }));
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 120, clientY: 10 }));
// 释放点 (320, 500) 不在表头/滚动区,也不在编辑器内:整体取消
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 320, clientY: 500 }));
expect(persist).not.toHaveBeenCalled();
// 对照:网格内释放仍提交重排序
layout.startColumnHeaderDrag(0, new PointerEvent("pointerdown", { button: 0, clientX: 50, clientY: 10 }));
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 180, clientY: 10 }));
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 180, clientY: 10 }));
expect(persist).toHaveBeenCalledWith([1, 0]);
scope.stop();
});
it("blocks native selection and drag while dragging columns", () => {
vi.stubGlobal(
"requestAnimationFrame",
vi.fn((callback: FrameRequestCallback) => {
callback(0);
return 0;
}),
);
vi.stubGlobal("cancelAnimationFrame", vi.fn());
const header = document.createElement("div");
const column = document.createElement("div");
column.dataset.visibleColIndex = "0";
column.getBoundingClientRect = () => ({ left: 0, width: 100, right: 100, top: 0, bottom: 20, height: 20, x: 0, y: 0, toJSON: () => ({}) });
header.append(column);
const scope = effectScope();
const layout = scope.run(() =>
useDataGridColumnLayout({
columnNames: ref(["id", "name"]),
visibleColumnIndexes: ref([0, 1]),
renderedColumnWidths: ref([100, 100]),
scrollLeft: ref(0),
viewportWidth: ref(400),
rowNumberWidth: 40,
headerRef: ref(header),
}),
)!;
layout.startColumnHeaderDrag(0, new PointerEvent("pointerdown", { button: 0, clientX: 50, clientY: 10 }));
// 拖拽期间原生选择与拖拽启动被拦截
const selectStart = new Event("selectstart", { cancelable: true });
document.dispatchEvent(selectStart);
expect(selectStart.defaultPrevented).toBe(true);
const dragStart = new Event("dragstart", { cancelable: true });
document.dispatchEvent(dragStart);
expect(dragStart.defaultPrevented).toBe(true);
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 150, clientY: 10 }));
expect(document.body.style.userSelect).toBe("");
// 手势结束后恢复原生效行为
const laterSelect = new Event("selectstart", { cancelable: true });
document.dispatchEvent(laterSelect);
expect(laterSelect.defaultPrevented).toBe(false);
scope.stop();
});
it("uses the edge after frozen columns as the left auto-scroll trigger", () => {
const frames = new Map<number, FrameRequestCallback>();
let nextFrame = 1;
@@ -50,8 +50,27 @@ type ColumnHeaderDragState = {
columnRects: { visibleIndex: number; left: number; width: number }[];
previewElement: HTMLElement | null;
dragging: boolean;
/** 指针进入 SQL 编辑器后切换为“插入列引用”模式,重排序预览挂起。 */
referenceMode: boolean;
/** 本次手势中 onEnter 已拒绝过该列(不可作为引用),不再重复试探。 */
referenceUnavailable: boolean;
};
/**
* 目标导向拖拽控制器:网格内保持列重排序手势;指针进入 SQL 编辑器区域时
* 由 DataGrid 提供的实现接管反馈与最终插入。
*/
export interface ColumnHeaderReferenceDragController {
isOverEditorTarget(clientX: number, clientY: number): boolean;
/** 进入编辑器目标时回调;返回 chip 文案,null 表示该列不可作为引用拖入。 */
onEnter(sourceVisibleIndex: number): string | null;
onMove(sourceVisibleIndex: number, clientX: number, clientY: number): void;
/** 在编辑器目标内释放时回调;返回 true 表示已处理插入。 */
onDrop(sourceVisibleIndex: number, clientX: number, clientY: number): boolean;
/** 引用模式结束(无论是否发生插入)时清理反馈。 */
onCancel(): void;
}
export function dataGridColumnOffsets(widths: readonly number[]): number[] {
const offsets = Array.from({ length: widths.length + 1 }, () => 0);
for (let index = 0; index < widths.length; index++) offsets[index + 1] = offsets[index] + (widths[index] ?? 0);
@@ -427,6 +446,7 @@ export function useDataGridColumnLayout(options: {
onRefreshMetrics?: () => void;
onPersistColumnOrder?: (indexes: number[]) => void;
frozenColumnCount?: MaybeRefOrGetter<number>;
columnReferenceDrag?: ColumnHeaderReferenceDragController;
}) {
const renderedColumnOffsets = computed(() => dataGridColumnOffsets(toValue(options.renderedColumnWidths)));
const frozenColumnCount = computed(() => toValue(options.frozenColumnCount ?? 0));
@@ -706,6 +726,8 @@ export function useDataGridColumnLayout(options: {
window.removeEventListener("pointerup", onColumnHeaderPointerUp, true);
window.removeEventListener("pointercancel", onColumnHeaderPointerCancel, true);
window.removeEventListener("blur", onColumnHeaderPointerCancel, true);
document.removeEventListener("selectstart", blockColumnHeaderNativeInteraction, true);
document.removeEventListener("dragstart", blockColumnHeaderNativeInteraction, true);
cancelColumnHeaderDragPreview();
removeColumnHeaderDragPreview(state);
document.body.style.userSelect = "";
@@ -723,6 +745,36 @@ export function useDataGridColumnLayout(options: {
options.onRefreshMetrics?.();
}
function enterColumnReferenceMode(state: ColumnHeaderDragState, clientX: number, clientY: number): boolean {
const controller = options.columnReferenceDrag;
if (!controller) return false;
const label = controller.onEnter(state.sourceVisibleIndex);
if (label == null) return false;
state.referenceMode = true;
cancelColumnHeaderDragPreview();
removeColumnHeaderDragPreview(state);
controller.onMove(state.sourceVisibleIndex, clientX, clientY);
return true;
}
function exitColumnReferenceMode(state: ColumnHeaderDragState) {
const controller = options.columnReferenceDrag;
state.referenceMode = false;
state.referenceUnavailable = false;
if (state.dragging) createColumnHeaderDragPreview(state);
controller?.onCancel();
}
/** 指针是否仍在本网格区域内(滚动区或表头行),用于“拖出网格释放=取消”。 */
function pointerInsideGridArea(clientX: number, clientY: number): boolean {
const rects: DOMRect[] = [];
const scroller = options.getScrollElement?.();
if (scroller) rects.push(scroller.getBoundingClientRect());
const header = toValue(options.headerRef);
if (header) rects.push(header.getBoundingClientRect());
return rects.some((rect) => clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom);
}
function onColumnHeaderPointerMove(event: PointerEvent) {
const state = columnHeaderDragState.value;
if (!state) return;
@@ -734,20 +786,59 @@ export function useDataGridColumnLayout(options: {
createColumnHeaderDragPreview(state);
}
if (!state.dragging) return;
const controller = options.columnReferenceDrag;
if (controller && !state.referenceUnavailable) {
const overEditor = controller.isOverEditorTarget(event.clientX, event.clientY);
if (overEditor && !state.referenceMode) {
// 进入编辑器:尝试切换为列引用模式;不可引用(onEnter 返回 null)时保持重排序并不再试探。
if (enterColumnReferenceMode(state, event.clientX, event.clientY)) return;
state.referenceUnavailable = true;
} else if (!overEditor && state.referenceMode) {
exitColumnReferenceMode(state);
}
}
if (state.referenceMode) {
event.preventDefault();
controller?.onMove(state.sourceVisibleIndex, event.clientX, event.clientY);
return;
}
event.preventDefault();
scheduleColumnHeaderDragPreview(event.clientX);
}
function onColumnHeaderPointerUp(event: PointerEvent) {
const state = columnHeaderDragState.value;
if (state?.referenceMode) {
const controller = options.columnReferenceDrag!;
if (controller.isOverEditorTarget(event.clientX, event.clientY)) {
// 在编辑器内释放:插入列引用(onDrop 失败也只按取消收尾)。
controller.onDrop(state.sourceVisibleIndex, event.clientX, event.clientY);
}
controller.onCancel();
stopColumnHeaderDrag(false);
return;
}
columnHeaderPendingClientX = event.clientX;
flushColumnHeaderDragPreview();
// 目标导向手势启用时,把列拖出网格与编辑器之外释放=取消,不重排列。
if (state?.dragging && options.columnReferenceDrag && !options.columnReferenceDrag.isOverEditorTarget(event.clientX, event.clientY) && !pointerInsideGridArea(event.clientX, event.clientY)) {
stopColumnHeaderDrag(false);
return;
}
stopColumnHeaderDrag(true);
}
function onColumnHeaderPointerCancel() {
const state = columnHeaderDragState.value;
if (state?.referenceMode) options.columnReferenceDrag?.onCancel();
stopColumnHeaderDrag(false);
}
/** 拖拽期间拦截原生文本选择与 HTML5 拖拽启动,防止其抢占指针事件流。 */
function blockColumnHeaderNativeInteraction(event: Event) {
event.preventDefault();
}
function startColumnHeaderDrag(visibleColIdx: number, event: PointerEvent) {
if (event.button !== 0 || options.getIsResizing?.() || columnHeaderInteractiveTarget(event.target)) return;
const scroller = options.getScrollElement?.();
@@ -755,6 +846,11 @@ export function useDataGridColumnLayout(options: {
const columnRects = columnHeaderLayoutRects();
const sourceRect = columnRects.find((rect) => rect.visibleIndex === visibleColIdx);
const dragCenterClientOffsetX = sourceRect ? sourceRect.left + sourceRect.width / 2 - event.clientX : 0;
// 阻止原生文本选择/HTML5 拖拽抢占事件流:一旦发生会派发 pointercancel 并停发 pointermove,
// 手势将被冻结(表现为拖不动)。参照侧边栏表引用路径在起点即禁用。
event.preventDefault();
document.addEventListener("selectstart", blockColumnHeaderNativeInteraction, true);
document.addEventListener("dragstart", blockColumnHeaderNativeInteraction, true);
columnHeaderDragState.value = {
sourceVisibleIndex: visibleColIdx,
targetVisibleIndex: visibleColIdx,
@@ -769,6 +865,8 @@ export function useDataGridColumnLayout(options: {
columnRects,
previewElement: null,
dragging: false,
referenceMode: false,
referenceUnavailable: false,
};
columnHeaderPendingClientX = event.clientX;
window.addEventListener("pointermove", onColumnHeaderPointerMove, true);
@@ -788,12 +886,12 @@ export function useDataGridColumnLayout(options: {
function columnHeaderDragClass(visibleColIdx: number) {
const state = columnHeaderDragState.value;
return { "opacity-0 pointer-events-none": state?.dragging && state.sourceVisibleIndex === visibleColIdx };
return { "opacity-0 pointer-events-none": state?.dragging && !state.referenceMode && state.sourceVisibleIndex === visibleColIdx };
}
function columnHeaderPreviewOffset(visibleColIdx: number): number {
const state = columnHeaderDragState.value;
if (!state) return 0;
if (!state || state.referenceMode) return 0;
const scrollCompensation = state.sourceVisibleIndex < frozenColumnCount.value ? 0 : state.currentScrollLeft - state.startScrollLeft;
return columnHeaderPreviewOffsetForColumn({
columnDragActive: state.dragging,
@@ -820,7 +918,7 @@ export function useDataGridColumnLayout(options: {
const columnHeaderPreviewOffsets = computed(() => toValue(options.renderedColumnWidths).map((_, visibleColIdx) => columnHeaderPreviewOffset(visibleColIdx)));
const columnHeaderPreviewSourceVisibleIndex = computed(() => {
const state = columnHeaderDragState.value;
return state?.dragging ? state.sourceVisibleIndex : null;
return state?.dragging && !state.referenceMode ? state.sourceVisibleIndex : null;
});
function disposeColumnHeaderInteractions() {
+1
View File
@@ -2054,6 +2054,7 @@ export default {
setNull: "Set NULL",
restoreOriginalValue: "Restore Original",
copyColumnName: "Copy Column Name",
columnDragChipMany: "{names} ({count} columns)",
copyAlterColumnSql: "Copy as SQL ALTER",
alterSqlCopied: "SQL ALTER copied to clipboard",
alterSqlCopiedWithWarnings: "SQL ALTER copied to clipboard ({count} warning(s))",
+1
View File
@@ -1912,6 +1912,7 @@ export default withEnglishFallback({
setNull: "Establecer NULL",
restoreOriginalValue: "Restaurar original",
copyColumnName: "Copiar nombre de columna",
columnDragChipMany: "{names} ({count} columnas)",
copySqlCondition: "Copiar condición SQL",
copyAlterColumnSql: "Copiar como SQL ALTER",
alterSqlCopied: "SQL ALTER copiado al portapapeles",
+1
View File
@@ -1910,6 +1910,7 @@ export default withEnglishFallback({
setNull: "Imposta NULL",
restoreOriginalValue: "Ripristina Originale",
copyColumnName: "Copia Nome Colonna",
columnDragChipMany: "{names} ({count} colonne)",
copyAlterColumnSql: "Copia come SQL ALTER",
alterSqlCopied: "SQL ALTER copiato negli appunti",
alterSqlCopiedWithWarnings: "SQL ALTER copiato negli appunti ({count} avviso/i)",
+1
View File
@@ -1928,6 +1928,7 @@ export default withEnglishFallback({
setNull: "NULLに設定",
restoreOriginalValue: "元の値に戻す",
copyColumnName: "列名をコピー",
columnDragChipMany: "{names}(全{count}列)",
copyAlterColumnSql: "SQL ALTERとしてコピー",
alterSqlCopied: "SQL ALTERをクリップボードにコピーしました",
alterSqlCopiedWithWarnings: "SQL ALTERをクリップボードにコピーしました({count}件の警告)",
+1
View File
@@ -1886,6 +1886,7 @@ export default withEnglishFallback({
setNull: "NULL 설정",
restoreOriginalValue: "원래대로 복원",
copyColumnName: "컬럼 이름 복사",
columnDragChipMany: "{names} (총 {count}개)",
copyAlterColumnSql: "SQL ALTER로 복사",
alterSqlCopied: "SQL ALTER를 클립보드에 복사했습니다",
alterSqlCopiedWithWarnings: "SQL ALTER를 클립보드에 복사했습니다 (경고 {count}개)",
+1
View File
@@ -1912,6 +1912,7 @@ export default withEnglishFallback({
setNull: "Definir NULL",
restoreOriginalValue: "Restaurar Original",
copyColumnName: "Copiar Nome da Coluna",
columnDragChipMany: "{names} ({count} colunas)",
copyAlterColumnSql: "Copiar como SQL ALTER",
alterSqlCopied: "SQL ALTER copiado para a área de transferência",
alterSqlCopiedWithWarnings: "SQL ALTER copiado para a área de transferência ({count} aviso(s))",
+1
View File
@@ -1979,6 +1979,7 @@ export default withEnglishFallback({
setNull: "设为 NULL",
restoreOriginalValue: "恢复原值",
copyColumnName: "复制列名",
columnDragChipMany: "{names} 等 {count} 列",
copyAlterColumnSql: "复制为SQL ALTER",
alterSqlCopied: "SQL ALTER 已复制到剪贴板",
alterSqlCopiedWithWarnings: "SQL ALTER 已复制到剪贴板(含 {count} 条警告)",
+1
View File
@@ -1916,6 +1916,7 @@ export default withEnglishFallback({
setNull: "設為 NULL",
restoreOriginalValue: "復原資料",
copyColumnName: "複製欄位名稱",
columnDragChipMany: "{names} 等 {count} 欄",
copyAlterColumnSql: "複製為 SQL ALTER",
alterSqlCopied: "SQL ALTER 已複製到剪貼簿",
alterSqlCopiedWithWarnings: "SQL ALTER 已複製到剪貼簿(含 {count} 條警告)",
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { createTableReferencePayload, parseTableReferencePayload, tableReferenceInsertText } from "@/lib/editor/queryEditorTableDrop";
import { createColumnReferencePayload, createTableReferencePayload, parseTableReferencePayload, tableReferenceInsertText } from "@/lib/editor/queryEditorTableDrop";
describe("query editor table reference drop", () => {
it("inserts a quoted database name for database references", () => {
@@ -38,4 +38,108 @@ describe("query editor table reference drop", () => {
expect(parseTableReferencePayload(JSON.stringify(payload))).toEqual(payload);
});
it("creates a column-only payload without a source table", () => {
const payload = createColumnReferencePayload({
connectionId: "conn-1",
database: "app-db",
columnNames: ["id", "order no"],
databaseType: "mysql",
});
expect(payload).toEqual({
kind: "dbx-table-reference",
connectionId: "conn-1",
database: "app-db",
columnNames: ["id", "order no"],
referenceType: "column",
databaseType: "mysql",
} satisfies Partial<typeof payload>);
expect(payload && "tableName" in payload).toBe(false);
});
it("rejects column payloads without connection, database, or names", () => {
expect(createColumnReferencePayload({ database: "db", columnNames: ["id"] })).toBeNull();
expect(createColumnReferencePayload({ connectionId: "conn-1", columnNames: ["id"] })).toBeNull();
expect(createColumnReferencePayload({ connectionId: "conn-1", database: "db", columnNames: [] })).toBeNull();
expect(createColumnReferencePayload({ connectionId: "conn-1", database: "db", columnNames: ["", " "] })).toBeNull();
});
it("trims surrounding whitespace from column names instead of inserting it verbatim", () => {
const payload = createColumnReferencePayload({
connectionId: "conn-1",
database: "db",
columnNames: [" id ", "name"],
})!;
expect(payload.columnNames).toEqual(["id", "name"]);
});
it("round-trips multi-column payloads and keeps legacy single-column payloads parseable", () => {
const multi = createColumnReferencePayload({
connectionId: "conn-1",
database: "app-db",
schema: "public",
columnNames: ["id", "name"],
databaseType: "postgres",
})!;
expect(parseTableReferencePayload(JSON.stringify(multi))).toEqual(multi);
const legacy = createTableReferencePayload({
connectionId: "conn-1",
database: "app-db",
tableName: "users",
columnName: "email",
databaseType: "postgres",
})!;
expect(parseTableReferencePayload(JSON.stringify(legacy))).toEqual(legacy);
});
it("inserts smart-quoted names per selected column joined by comma + newline", () => {
// 普通名称裸输出,保留字/含特殊字符才加方言引号
const mysql = createColumnReferencePayload({
connectionId: "conn-1",
database: "app-db",
columnNames: ["id", "order no", "order", "created_at"],
databaseType: "mysql",
})!;
expect(tableReferenceInsertText(mysql)).toBe("id,\n`order no`,\n`order`,\ncreated_at");
const postgres = createColumnReferencePayload({
connectionId: "conn-1",
database: "app-db",
columnNames: ["id", "OrderNo", "select"],
databaseType: "postgres",
})!;
expect(tableReferenceInsertText(postgres)).toBe('id,\n"OrderNo",\n"select"');
});
it("keeps smart quoting for dialects without a dedicated keyword table", () => {
// 未知方言用通用保守判定:普通名裸输出,特殊字符走全量引号回退
const oracle = createColumnReferencePayload({
connectionId: "conn-1",
database: "app-db",
columnNames: ["id", "order no"],
databaseType: "oracle",
})!;
expect(tableReferenceInsertText(oracle)).toBe('id,\n"order no"');
});
it("bare-quotes sqlserver columns when safe and brackets reserved words", () => {
const payload = createColumnReferencePayload({
connectionId: "conn-1",
database: "app-db",
columnNames: ["id", "order", "order no"],
databaseType: "sqlserver",
})!;
expect(tableReferenceInsertText(payload)).toBe("id,\n[order],\n[order no]");
});
it("falls back to the editor database type when the payload omits one", () => {
const payload = createColumnReferencePayload({
connectionId: "conn-1",
database: "app-db",
columnNames: ["order no"],
})!;
expect(tableReferenceInsertText(payload, "mysql")).toBe("`order no`");
});
});
@@ -0,0 +1,107 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from "vitest";
import { beginTableReferenceDragFeedback, isOverSqlEditorTarget, isPointOverElementRoot } from "@/lib/editor/tableReferenceDragFeedback";
afterEach(() => {
document.body.innerHTML = "";
document.body.className = "";
document.body.style.cursor = "";
});
describe("isOverSqlEditorTarget", () => {
it("detects pointers inside the query editor root only", () => {
const editor = document.createElement("div");
editor.setAttribute("data-query-editor-root", "");
const inner = document.createElement("textarea");
editor.appendChild(inner);
document.body.appendChild(editor);
document.elementFromPoint = () => inner;
expect(isOverSqlEditorTarget(10, 10)).toBe(true);
document.elementFromPoint = () => document.body;
expect(isOverSqlEditorTarget(10, 10)).toBe(false);
});
it("falls back to geometric bounds when elementFromPoint hits an overlay", () => {
const editor = document.createElement("div");
editor.setAttribute("data-query-editor-root", "");
Object.defineProperty(editor, "getBoundingClientRect", {
value: () => ({ left: 0, top: 0, right: 400, bottom: 300 }),
});
document.body.appendChild(editor);
// 命中被透明覆盖层拦截时,回退为编辑器包围盒判定。
document.elementFromPoint = () => null;
expect(isOverSqlEditorTarget(10, 10)).toBe(true);
// 覆盖层命中但不落在任何编辑器包围盒内仍为 false。
const farEditor = document.createElement("div");
farEditor.setAttribute("data-query-editor-root", "");
Object.defineProperty(farEditor, "getBoundingClientRect", {
value: () => ({ left: 500, top: 500, right: 900, bottom: 800 }),
});
document.body.appendChild(farEditor);
document.elementFromPoint = () => null;
expect(isOverSqlEditorTarget(450, 450)).toBe(false);
expect(isOverSqlEditorTarget(600, 600)).toBe(true);
});
});
describe("isPointOverElementRoot", () => {
it("requires elementFromPoint to hit inside the given root", () => {
const editor = document.createElement("div");
const inner = document.createElement("textarea");
editor.appendChild(inner);
document.body.appendChild(editor);
document.elementFromPoint = () => inner;
expect(isPointOverElementRoot(10, 10, editor)).toBe(true);
document.elementFromPoint = () => document.body;
expect(isPointOverElementRoot(10, 10, editor)).toBe(false);
expect(isPointOverElementRoot(10, 10, null)).toBe(false);
});
it("falls back to the root bounding box when elementFromPoint hits an overlay", () => {
const editor = document.createElement("div");
Object.defineProperty(editor, "getBoundingClientRect", {
value: () => ({ left: 0, top: 0, right: 400, bottom: 300 }),
});
document.body.appendChild(editor);
// 覆盖层拦截时回退为传入根节点的包围盒判定。
const overlay = document.createElement("div");
document.elementFromPoint = () => overlay;
expect(isPointOverElementRoot(10, 10, editor)).toBe(true);
expect(isPointOverElementRoot(450, 450, editor)).toBe(false);
});
});
describe("beginTableReferenceDragFeedback", () => {
it("shows a following chip and restores body state on end", () => {
const feedback = beginTableReferenceDragFeedback("id, name 等 3 列");
const chip = document.querySelector<HTMLElement>("[data-table-reference-drag-chip]");
expect(chip?.textContent).toBe("id, name 等 3 列");
expect(document.body.style.cursor).toBe("copy");
expect(chip?.style.visibility).toBe("hidden");
feedback.update(40, 60);
expect(chip?.style.visibility).toBe("visible");
expect(Number.parseFloat(chip!.style.left)).toBeGreaterThanOrEqual(8);
expect(Number.parseFloat(chip!.style.top)).toBeGreaterThanOrEqual(8);
feedback.end();
expect(document.querySelector("[data-table-reference-drag-chip]")).toBeNull();
expect(document.body.style.cursor).toBe("");
});
it("clamps the chip inside the viewport", () => {
const feedback = beginTableReferenceDragFeedback("col");
const chip = document.querySelector<HTMLElement>("[data-table-reference-drag-chip]")!;
feedback.update(window.innerWidth - 2, window.innerHeight - 2);
const left = Number.parseFloat(chip.style.left);
const top = Number.parseFloat(chip.style.top);
expect(left + chip.getBoundingClientRect().width).toBeLessThanOrEqual(window.innerWidth);
expect(top + chip.getBoundingClientRect().height).toBeLessThanOrEqual(window.innerHeight);
feedback.end();
});
});
@@ -1,8 +1,41 @@
import type { DatabaseType } from "@/types/database";
import { qualifiedTableName, quoteTableIdentifier } from "@/lib/table/tableSelectSql";
import { requiresMysqlIdentifierQuote, requiresPostgresIdentifierQuote } from "@/lib/sql/sqlIdentifier.ts";
/** 智能引号使用反引号的方言族。 */
const SMART_QUOTE_BACKTICK_TYPES = new Set<DatabaseType>(["mysql", "clickhouse", "hive", "kyuubi", "impala", "spark", "databricks", "databend", "tdengine", "access", "doris", "starrocks", "goldendb"]);
/** 智能引号使用双引号的方言族(SQL Server 方括号除外)。 */
const SMART_QUOTE_DOUBLE_TYPES = new Set<DatabaseType>(["postgres", "gaussdb", "opengauss"]);
/** SQL Server 族智能引号使用方括号。 */
const SMART_QUOTE_BRACKET_TYPES = new Set<DatabaseType>(["sqlserver"]);
/**
* 列引用插入的按需引号:普通名称(合法标识符字符、非保留字)裸输出,
* 保留字或含特殊字符时按方言加引号。表名/库名仍走全量引号。
*
* MySQL/PG/SQL Server 族用各自的保留字判定;其余方言无专属保留字表,
* 用「严格标识符正则 + PG/MySQL 保留字并集」做通用保守判定。
*/
function quoteColumnReferenceName(databaseType: DatabaseType | undefined, name: string): string {
if (databaseType && SMART_QUOTE_BACKTICK_TYPES.has(databaseType)) {
return requiresMysqlIdentifierQuote(name) ? `\`${name.replace(/`/g, "``")}\`` : name;
}
if (databaseType && SMART_QUOTE_DOUBLE_TYPES.has(databaseType)) {
// PG 族裸标识符会折叠为小写,混合大小写必须加引号保留原样。
return requiresPostgresIdentifierQuote(name) ? `"${name.replace(/"/g, '""')}"` : name;
}
if (databaseType && SMART_QUOTE_BRACKET_TYPES.has(databaseType)) {
return requiresPostgresIdentifierQuote(name) ? `[${name.replace(/\]/g, "]]")}]` : name;
}
// 其余方言:引号格式仍由 quoteTableIdentifier 按方言决定,是否加引号
// 用通用保守判定(严格标识符正则 + PG/MySQL 保留字并集)。
return requiresMysqlIdentifierQuote(name) ? quoteTableIdentifier(databaseType, name) : name;
}
export const DBX_TABLE_REFERENCE_MIME = "application/x-dbx-table-reference";
export const DBX_TABLE_REFERENCE_DROP_EVENT = "dbx-table-reference-drop";
export const DBX_TABLE_REFERENCE_HOVER_EVENT = "dbx-table-reference-hover";
export const DBX_TABLE_REFERENCE_DRAG_END_EVENT = "dbx-table-reference-drag-end";
export interface QueryEditorTableReferencePayload {
kind: "dbx-table-reference";
@@ -11,6 +44,8 @@ export interface QueryEditorTableReferencePayload {
schema?: string;
tableName?: string;
columnName?: string;
/** 多个结果列一起拖入时按选择顺序排列;单列沿用 columnName。 */
columnNames?: string[];
referenceType?: "database" | "table" | "column";
databaseType?: DatabaseType;
driverProfile?: string;
@@ -22,6 +57,23 @@ export interface QueryEditorTableReferenceDropDetail {
clientY: number;
}
export interface QueryEditorTableReferenceHoverDetail {
clientX: number;
clientY: number;
}
function normalizeColumnNames(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((name) => (typeof name === "string" ? name.trim() : "")).filter((name) => name.length > 0);
}
/** schema/databaseType/driverProfile 为可选元数据,payload 各构造路径共用同一拷贝规则。 */
function applyOptionalReferenceMeta(payload: QueryEditorTableReferencePayload, source: Partial<QueryEditorTableReferencePayload>) {
if (typeof source.schema === "string" && source.schema) payload.schema = source.schema;
if (source.databaseType) payload.databaseType = source.databaseType;
if (typeof source.driverProfile === "string" && source.driverProfile) payload.driverProfile = source.driverProfile;
}
let activeTableReferencePayload: QueryEditorTableReferencePayload | null = null;
export function createTableReferencePayload(options: {
@@ -51,9 +103,21 @@ export function createTableReferencePayload(options: {
payload.columnName = options.columnName;
payload.referenceType = "column";
}
if (options.schema) payload.schema = options.schema;
if (options.databaseType) payload.databaseType = options.databaseType;
if (options.driverProfile) payload.driverProfile = options.driverProfile;
applyOptionalReferenceMeta(payload, options);
return payload;
}
export function createColumnReferencePayload(options: { connectionId?: string; database?: string; schema?: string; columnNames?: readonly (string | undefined | null)[]; databaseType?: DatabaseType }): QueryEditorTableReferencePayload | null {
const columnNames = normalizeColumnNames(options.columnNames);
if (!options.connectionId || options.database == null || columnNames.length === 0) return null;
const payload: QueryEditorTableReferencePayload = {
kind: "dbx-table-reference",
connectionId: options.connectionId,
database: options.database,
referenceType: "column",
columnNames,
};
applyOptionalReferenceMeta(payload, options as Partial<QueryEditorTableReferencePayload>);
return payload;
}
@@ -79,7 +143,20 @@ export function parseTableReferencePayload(value: string | undefined | null): Qu
if (typeof parsed.driverProfile === "string" && parsed.driverProfile) payload.driverProfile = parsed.driverProfile;
return payload;
}
if (typeof parsed.tableName !== "string" || !parsed.tableName) return null;
if (typeof parsed.tableName !== "string" || !parsed.tableName) {
// 无来源表的纯列引用(如从查询结果列头拖入),tableName 非必需。
const columnNames = normalizeColumnNames(parsed.columnNames);
if (columnNames.length === 0) return null;
const payload: QueryEditorTableReferencePayload = {
kind: "dbx-table-reference",
connectionId: parsed.connectionId,
database: parsed.database,
referenceType: "column",
columnNames,
};
applyOptionalReferenceMeta(payload, parsed);
return payload;
}
const columnName = typeof parsed.columnName === "string" && parsed.columnName ? parsed.columnName : undefined;
const referenceType = parsed.referenceType === "column" || columnName ? "column" : "table";
if (referenceType === "column" && !columnName) return null;
@@ -93,9 +170,7 @@ export function parseTableReferencePayload(value: string | undefined | null): Qu
payload.columnName = columnName;
payload.referenceType = "column";
}
if (typeof parsed.schema === "string" && parsed.schema) payload.schema = parsed.schema;
if (parsed.databaseType) payload.databaseType = parsed.databaseType;
if (typeof parsed.driverProfile === "string" && parsed.driverProfile) payload.driverProfile = parsed.driverProfile;
applyOptionalReferenceMeta(payload, parsed);
return payload;
} catch {
return null;
@@ -128,13 +203,22 @@ export function createTableReferenceDropEvent(detail: QueryEditorTableReferenceD
return new CustomEvent<QueryEditorTableReferenceDropDetail>(DBX_TABLE_REFERENCE_DROP_EVENT, { detail });
}
export function createTableReferenceHoverEvent(detail: QueryEditorTableReferenceHoverDetail) {
return new CustomEvent<QueryEditorTableReferenceHoverDetail>(DBX_TABLE_REFERENCE_HOVER_EVENT, { detail });
}
export function createTableReferenceDragEndEvent(): Event {
return new Event(DBX_TABLE_REFERENCE_DRAG_END_EVENT);
}
export function tableReferenceInsertText(payload: QueryEditorTableReferencePayload, fallbackDatabaseType?: DatabaseType): string {
const databaseType = payload.databaseType ?? fallbackDatabaseType;
if (payload.referenceType === "database") {
return quoteTableIdentifier(databaseType, payload.database);
}
if (payload.referenceType === "column" && payload.columnName) {
return quoteTableIdentifier(databaseType, payload.columnName);
const columnNames = payload.columnNames?.length ? payload.columnNames : payload.columnName ? [payload.columnName] : [];
if (payload.referenceType === "column" && columnNames.length > 0) {
return columnNames.map((name) => quoteColumnReferenceName(databaseType, name)).join(",\n");
}
const tableName = payload.tableName || payload.database;
return qualifiedTableName({
@@ -0,0 +1,70 @@
/**
* 表引用拖拽(侧边栏表/列、查询结果列头)共用的指针模拟拖拽反馈:
* 跟随鼠标的 chip 浮层 + body copy 光标。落点探测与插入见 queryEditorTableDrop.ts。
*/
export const QUERY_EDITOR_DROP_TARGET_SELECTOR = "[data-query-editor-root]";
/** 拖拽期间加在 body 上,禁用文本选择(样式见 globals.css)。 */
export const TABLE_REFERENCE_DRAGGING_CLASS = "dbx-table-reference-dragging";
const CHIP_OFFSET_X = 14;
const CHIP_OFFSET_Y = 18;
const CHIP_VIEWPORT_MARGIN = 8;
/**
* elementFromPoint 命中 root 内部才算命中;elementFromPoint 可能被透明覆盖层
* 拦截(面板层等),此时回退为 root 包围盒的几何包含判定。
*/
export function isPointOverElementRoot(clientX: number, clientY: number, root: Element | null | undefined, doc: Document = document): boolean {
if (!root) return false;
const target = doc.elementFromPoint(clientX, clientY);
if (target instanceof Element && root.contains(target)) return true;
const rect = root.getBoundingClientRect();
return clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom;
}
export function isOverSqlEditorTarget(clientX: number, clientY: number, doc: Document = document): boolean {
for (const root of doc.querySelectorAll(QUERY_EDITOR_DROP_TARGET_SELECTOR)) {
if (isPointOverElementRoot(clientX, clientY, root, doc)) return true;
}
return false;
}
/** 多列摘要由 i18n 插值完成:调用方以 { names: 前两个列名, count: 总数 } 调 t("grid.columnDragChipMany", ...)。 */
export interface TableReferenceDragFeedback {
update(clientX: number, clientY: number): void;
end(): void;
}
export function beginTableReferenceDragFeedback(label: string, doc: Document = document): TableReferenceDragFeedback {
const chip = doc.createElement("div");
chip.dataset.tableReferenceDragChip = "";
chip.setAttribute("aria-hidden", "true");
chip.className = "pointer-events-none fixed z-[9999] max-w-72 truncate rounded-md border border-border bg-popover px-2 py-1 font-mono text-xs leading-4 text-popover-foreground shadow-lg";
chip.textContent = label;
chip.style.visibility = "hidden";
doc.body.appendChild(chip);
doc.body.classList.add(TABLE_REFERENCE_DRAGGING_CLASS);
doc.body.style.cursor = "copy";
const viewport = doc.defaultView ?? window;
const update = (clientX: number, clientY: number) => {
if (chip.style.visibility === "hidden") chip.style.visibility = "visible";
const rect = chip.getBoundingClientRect();
const x = Math.min(clientX + CHIP_OFFSET_X, viewport.innerWidth - rect.width - CHIP_VIEWPORT_MARGIN);
const y = Math.min(clientY + CHIP_OFFSET_Y, viewport.innerHeight - rect.height - CHIP_VIEWPORT_MARGIN);
chip.style.left = `${Math.max(CHIP_VIEWPORT_MARGIN, x)}px`;
chip.style.top = `${Math.max(CHIP_VIEWPORT_MARGIN, y)}px`;
};
return {
update,
end() {
chip.remove();
doc.body.classList.remove(TABLE_REFERENCE_DRAGGING_CLASS);
if (doc.body.style.cursor === "copy") doc.body.style.cursor = "";
},
};
}