feat(editor): support dragging selected SQL text

This commit is contained in:
t8y2 committed 2026-07-06 22:01:26 +08:00
1 parent 1480e08d17
commit 9ad9261a6d
1 file changed
+147 -1
@@ -247,7 +247,10 @@ let pendingImeModelEmit = false;
type SelectionCaseMode = "upper" | "lower";
let executableStatementRangeCache: ExecutableStatementRangeCache | null = null;
let editorScrollbarPointerCleanup: (() => void) | null = null;
let editorSelectionDragCleanup: (() => void) | null = null;
let editorSelectionDropCursorEl: HTMLDivElement | null = null;
const EDITOR_SCROLLBAR_POINTER_GUTTER_PX = 18;
const EDITOR_SELECTION_DRAG_THRESHOLD_PX = 6;
const tableNavigationHoverClass = "query-editor--table-navigation-hover";
const DBX_VIM_SAVE_EVENT = "dbx-vim-save";
@@ -542,6 +545,144 @@ function registerEditorScrollbarPointerGuard(currentView: EditorViewType) {
};
}
function selectedRangeAtPointer(currentView: EditorViewType, event: MouseEvent) {
if (props.readOnly || event.button !== 0) return null;
if (!currentView.contentDOM.contains(event.target as Node | null)) return null;
const range = currentView.state.selection.main;
if (range.empty) return null;
const pos = currentView.posAtCoords({ x: event.clientX, y: event.clientY }, false);
if (pos == null || pos < range.from || pos > range.to) return null;
return { from: range.from, to: range.to, text: currentView.state.sliceDoc(range.from, range.to) };
}
function moveOrCopySelectionToPointer(currentView: EditorViewType, selection: { from: number; to: number; text: string }, event: MouseEvent) {
const dropPos = currentView.posAtCoords({ x: event.clientX, y: event.clientY }, false);
if (dropPos == null) return false;
const copy = event.ctrlKey || event.metaKey;
if (!copy && dropPos >= selection.from && dropPos <= selection.to) return true;
const insert = { from: dropPos, insert: selection.text };
const changes = copy ? currentView.state.changes(insert) : currentView.state.changes([{ from: selection.from, to: selection.to }, insert]);
currentView.dispatch({
changes,
selection: {
anchor: changes.mapPos(dropPos, -1),
head: changes.mapPos(dropPos, 1),
},
scrollIntoView: true,
userEvent: copy ? "input.drop" : "move.drop",
});
currentView.focus();
return true;
}
function hideEditorSelectionDropCursor() {
editorSelectionDropCursorEl?.remove();
editorSelectionDropCursorEl = null;
}
function updateEditorSelectionDropCursor(currentView: EditorViewType, event: MouseEvent) {
const pos = currentView.posAtCoords({ x: event.clientX, y: event.clientY }, false);
if (pos == null) {
hideEditorSelectionDropCursor();
return;
}
const coords = currentView.coordsAtPos(pos);
if (!coords) {
hideEditorSelectionDropCursor();
return;
}
const ownerDocument = currentView.dom.ownerDocument;
const cursor = editorSelectionDropCursorEl ?? ownerDocument.createElement("div");
if (!editorSelectionDropCursorEl) {
cursor.setAttribute("aria-hidden", "true");
cursor.className = "dbx-editor-selection-drop-cursor";
// Use a fixed overlay instead of CodeMirror's internal drop cursor layer so
// the marker stays visible above selection layers, themes, and scrollers.
cursor.style.position = "fixed";
cursor.style.zIndex = "2147483647";
cursor.style.width = "2px";
cursor.style.pointerEvents = "none";
cursor.style.backgroundImage = "repeating-linear-gradient(to bottom, #e879f9 0 4px, transparent 4px 7px)";
cursor.style.filter = "drop-shadow(0 0 1px rgba(0, 0, 0, 0.7))";
ownerDocument.body.appendChild(cursor);
editorSelectionDropCursorEl = cursor;
}
cursor.style.left = `${Math.round(coords.left) - 1}px`;
cursor.style.top = `${Math.round(coords.top)}px`;
cursor.style.height = `${Math.max(16, Math.round(coords.bottom - coords.top))}px`;
}
function startEditorSelectionDrag(currentView: EditorViewType, event: MouseEvent): boolean {
const selection = selectedRangeAtPointer(currentView, event);
if (!selection) return false;
event.preventDefault();
event.stopPropagation();
if (!event.ctrlKey && !event.metaKey) {
emit("closeColumnPanel");
}
editorSelectionDragCleanup?.();
const startX = event.clientX;
const startY = event.clientY;
let dragging = false;
const cleanup = () => {
currentView.contentDOM.ownerDocument.removeEventListener("mousemove", onMove, true);
currentView.contentDOM.ownerDocument.removeEventListener("mouseup", onUp, true);
currentView.contentDOM.ownerDocument.removeEventListener("keydown", onKeyDown, true);
hideEditorSelectionDropCursor();
editorSelectionDragCleanup = null;
};
const onMove = (moveEvent: MouseEvent) => {
const distance = Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY);
if (!dragging && distance < EDITOR_SELECTION_DRAG_THRESHOLD_PX) return;
dragging = true;
if (moveEvent.ctrlKey || moveEvent.metaKey) {
currentView.contentDOM.style.cursor = "copy";
} else {
currentView.contentDOM.style.cursor = "move";
}
updateEditorSelectionDropCursor(currentView, moveEvent);
moveEvent.preventDefault();
moveEvent.stopImmediatePropagation();
};
const onUp = (upEvent: MouseEvent) => {
cleanup();
currentView.contentDOM.style.cursor = "";
upEvent.preventDefault();
upEvent.stopImmediatePropagation();
if (dragging) {
moveOrCopySelectionToPointer(currentView, selection, upEvent);
return;
}
const pos = currentView.posAtCoords({ x: upEvent.clientX, y: upEvent.clientY });
if (pos != null) {
currentView.dispatch({ selection: { anchor: pos }, userEvent: "select.pointer" });
currentView.focus();
}
};
const onKeyDown = (keyEvent: KeyboardEvent) => {
if (keyEvent.key !== "Escape") return;
cleanup();
currentView.contentDOM.style.cursor = "";
keyEvent.preventDefault();
keyEvent.stopImmediatePropagation();
};
currentView.contentDOM.ownerDocument.addEventListener("mousemove", onMove, true);
currentView.contentDOM.ownerDocument.addEventListener("mouseup", onUp, true);
currentView.contentDOM.ownerDocument.addEventListener("keydown", onKeyDown, true);
editorSelectionDragCleanup = () => {
cleanup();
currentView.contentDOM.style.cursor = "";
};
return true;
}
function executeFromContextMenu() {
if (!canExecuteContextSql.value) return;
requestExecute();
@@ -2472,6 +2613,7 @@ onMounted(async () => {
trimmedSelectionLayer(),
selectionMatchOccurrences(),
dropCursor(),
EditorView.dragMovesSelection.of((event) => !event.ctrlKey && !event.metaKey),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
@@ -2593,6 +2735,10 @@ onMounted(async () => {
},
mousedown: (event: MouseEvent) => {
clearTableNavigationHover();
const currentView = view.value;
if (currentView && startEditorSelectionDrag(currentView, event)) {
return true;
}
// Click without modifier -> close column panel
if (!event.metaKey && !event.ctrlKey) {
if (event.button === 0) {
@@ -2603,7 +2749,6 @@ onMounted(async () => {
// Only handle Ctrl/Cmd + left click
if (event.button !== 0) return false;
const currentView = view.value;
if (!currentView || !props.connectionId || props.database == null) {
return false;
}
@@ -2913,6 +3058,7 @@ onBeforeUnmount(() => {
viewportRestoreFrame = null;
}
editorScrollbarPointerCleanup?.();
editorSelectionDragCleanup?.();
view.value?.scrollDOM.removeEventListener("scroll", scheduleEditorViewportEmit);
window.removeEventListener("keyup", clearTableNavigationHoverOnModifierRelease);
window.removeEventListener("blur", clearTableNavigationHover);