mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
feat(editor): drag result grid columns into the SQL editor
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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))",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -1928,6 +1928,7 @@ export default withEnglishFallback({
|
||||
setNull: "NULLに設定",
|
||||
restoreOriginalValue: "元の値に戻す",
|
||||
copyColumnName: "列名をコピー",
|
||||
columnDragChipMany: "{names}(全{count}列)",
|
||||
copyAlterColumnSql: "SQL ALTERとしてコピー",
|
||||
alterSqlCopied: "SQL ALTERをクリップボードにコピーしました",
|
||||
alterSqlCopiedWithWarnings: "SQL ALTERをクリップボードにコピーしました({count}件の警告)",
|
||||
|
||||
@@ -1886,6 +1886,7 @@ export default withEnglishFallback({
|
||||
setNull: "NULL 설정",
|
||||
restoreOriginalValue: "원래대로 복원",
|
||||
copyColumnName: "컬럼 이름 복사",
|
||||
columnDragChipMany: "{names} (총 {count}개)",
|
||||
copyAlterColumnSql: "SQL ALTER로 복사",
|
||||
alterSqlCopied: "SQL ALTER를 클립보드에 복사했습니다",
|
||||
alterSqlCopiedWithWarnings: "SQL ALTER를 클립보드에 복사했습니다 (경고 {count}개)",
|
||||
|
||||
@@ -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))",
|
||||
|
||||
@@ -1979,6 +1979,7 @@ export default withEnglishFallback({
|
||||
setNull: "设为 NULL",
|
||||
restoreOriginalValue: "恢复原值",
|
||||
copyColumnName: "复制列名",
|
||||
columnDragChipMany: "{names} 等 {count} 列",
|
||||
copyAlterColumnSql: "复制为SQL ALTER",
|
||||
alterSqlCopied: "SQL ALTER 已复制到剪贴板",
|
||||
alterSqlCopiedWithWarnings: "SQL ALTER 已复制到剪贴板(含 {count} 条警告)",
|
||||
|
||||
@@ -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 = "";
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user