diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 985bb9926e..0fc260ef88 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -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);