From ab4e747bf961bc911f112d154a8d3a5a35487f94 Mon Sep 17 00:00:00 2001 From: Yang Fei Date: Thu, 27 Aug 2026 17:16:57 +0800 Subject: [PATCH] feat(editor): drag result grid columns into the SQL editor --- .../src/components/editor/QueryEditor.vue | 77 ++++- apps/desktop/src/components/grid/DataGrid.vue | 61 +++- .../src/components/sidebar/TreeItem.vue | 25 +- .../__tests__/useDataGridColumnLayout.spec.ts | 276 +++++++++++++++++- .../composables/useDataGridColumnLayout.ts | 104 ++++++- apps/desktop/src/i18n/locales/en.ts | 1 + apps/desktop/src/i18n/locales/es.ts | 1 + apps/desktop/src/i18n/locales/it.ts | 1 + apps/desktop/src/i18n/locales/ja.ts | 1 + apps/desktop/src/i18n/locales/ko.ts | 1 + apps/desktop/src/i18n/locales/pt-BR.ts | 1 + apps/desktop/src/i18n/locales/zh-CN.ts | 1 + apps/desktop/src/i18n/locales/zh-TW.ts | 1 + .../editor/queryEditorTableDrop.spec.ts | 106 ++++++- .../editor/tableReferenceDragFeedback.spec.ts | 107 +++++++ .../src/lib/editor/queryEditorTableDrop.ts | 102 ++++++- .../lib/editor/tableReferenceDragFeedback.ts | 70 +++++ 17 files changed, 911 insertions(+), 25 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/editor/tableReferenceDragFeedback.spec.ts create mode 100644 apps/desktop/src/lib/editor/tableReferenceDragFeedback.ts diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index f9f96d3613..14cfa90d49 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -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({ " /> +
diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index af695a0385..ad75f68680 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -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) { diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 8c95862643..3daa1eb36d 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -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); diff --git a/apps/desktop/src/composables/__tests__/useDataGridColumnLayout.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridColumnLayout.spec.ts index 1364da353c..d7d5663a8e 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridColumnLayout.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridColumnLayout.spec.ts @@ -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(); let nextFrame = 1; diff --git a/apps/desktop/src/composables/useDataGridColumnLayout.ts b/apps/desktop/src/composables/useDataGridColumnLayout.ts index 0c5dd3a9f7..c024ab6674 100644 --- a/apps/desktop/src/composables/useDataGridColumnLayout.ts +++ b/apps/desktop/src/composables/useDataGridColumnLayout.ts @@ -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; + 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() { diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 8b682efcb8..3cfb5126bb 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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))", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index c2d53bd63e..a21c827fb4 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 9458ca1de0..4b32a7c42b 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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)", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index e06ac5ae77..3fb71f6d1e 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1928,6 +1928,7 @@ export default withEnglishFallback({ setNull: "NULLに設定", restoreOriginalValue: "元の値に戻す", copyColumnName: "列名をコピー", + columnDragChipMany: "{names}(全{count}列)", copyAlterColumnSql: "SQL ALTERとしてコピー", alterSqlCopied: "SQL ALTERをクリップボードにコピーしました", alterSqlCopiedWithWarnings: "SQL ALTERをクリップボードにコピーしました({count}件の警告)", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index e9ef63be6f..d57a0f80c0 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -1886,6 +1886,7 @@ export default withEnglishFallback({ setNull: "NULL 설정", restoreOriginalValue: "원래대로 복원", copyColumnName: "컬럼 이름 복사", + columnDragChipMany: "{names} (총 {count}개)", copyAlterColumnSql: "SQL ALTER로 복사", alterSqlCopied: "SQL ALTER를 클립보드에 복사했습니다", alterSqlCopiedWithWarnings: "SQL ALTER를 클립보드에 복사했습니다 (경고 {count}개)", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 88d0ae9553..f7b98ec453 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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))", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index d26f9b4219..5eb957bcef 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1979,6 +1979,7 @@ export default withEnglishFallback({ setNull: "设为 NULL", restoreOriginalValue: "恢复原值", copyColumnName: "复制列名", + columnDragChipMany: "{names} 等 {count} 列", copyAlterColumnSql: "复制为SQL ALTER", alterSqlCopied: "SQL ALTER 已复制到剪贴板", alterSqlCopiedWithWarnings: "SQL ALTER 已复制到剪贴板(含 {count} 条警告)", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 30ac83401b..671a1751f7 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1916,6 +1916,7 @@ export default withEnglishFallback({ setNull: "設為 NULL", restoreOriginalValue: "復原資料", copyColumnName: "複製欄位名稱", + columnDragChipMany: "{names} 等 {count} 欄", copyAlterColumnSql: "複製為 SQL ALTER", alterSqlCopied: "SQL ALTER 已複製到剪貼簿", alterSqlCopiedWithWarnings: "SQL ALTER 已複製到剪貼簿(含 {count} 條警告)", diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorTableDrop.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorTableDrop.spec.ts index 4b34c59fb9..a8a86fbf85 100644 --- a/apps/desktop/src/lib/__tests__/editor/queryEditorTableDrop.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorTableDrop.spec.ts @@ -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); + 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`"); + }); }); diff --git a/apps/desktop/src/lib/__tests__/editor/tableReferenceDragFeedback.spec.ts b/apps/desktop/src/lib/__tests__/editor/tableReferenceDragFeedback.spec.ts new file mode 100644 index 0000000000..1ed4f4f1d3 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/tableReferenceDragFeedback.spec.ts @@ -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("[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("[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(); + }); +}); diff --git a/apps/desktop/src/lib/editor/queryEditorTableDrop.ts b/apps/desktop/src/lib/editor/queryEditorTableDrop.ts index c11205092a..1c8aa55ac6 100644 --- a/apps/desktop/src/lib/editor/queryEditorTableDrop.ts +++ b/apps/desktop/src/lib/editor/queryEditorTableDrop.ts @@ -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(["mysql", "clickhouse", "hive", "kyuubi", "impala", "spark", "databricks", "databend", "tdengine", "access", "doris", "starrocks", "goldendb"]); +/** 智能引号使用双引号的方言族(SQL Server 方括号除外)。 */ +const SMART_QUOTE_DOUBLE_TYPES = new Set(["postgres", "gaussdb", "opengauss"]); +/** SQL Server 族智能引号使用方括号。 */ +const SMART_QUOTE_BRACKET_TYPES = new Set(["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) { + 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); 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(DBX_TABLE_REFERENCE_DROP_EVENT, { detail }); } +export function createTableReferenceHoverEvent(detail: QueryEditorTableReferenceHoverDetail) { + return new CustomEvent(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({ diff --git a/apps/desktop/src/lib/editor/tableReferenceDragFeedback.ts b/apps/desktop/src/lib/editor/tableReferenceDragFeedback.ts new file mode 100644 index 0000000000..a9de36c2ae --- /dev/null +++ b/apps/desktop/src/lib/editor/tableReferenceDragFeedback.ts @@ -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 = ""; + }, + }; +}