diff --git a/services/web/frontend/js/features/source-editor/extensions/draggable-cursor.ts b/services/web/frontend/js/features/source-editor/extensions/draggable-cursor.ts new file mode 100644 index 0000000000..a29f8dd998 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/draggable-cursor.ts @@ -0,0 +1,64 @@ +import { Extension } from '@codemirror/state' +import { EditorView, ViewPlugin } from '@codemirror/view' +import { isInPrimarySelection } from './visual/utils/selection' + +const showDraggable = { style: 'cursor: move' } + +/** + * An extension that changes the cursor style to "move" when the main mouse button + * is held down on the primary selection for a short amount of time. + */ +export const draggableCursor = (): Extension => { + let timer: number | undefined + + const plugin = ViewPlugin.define( + view => { + return { + isActive: false, + set(isActive: boolean) { + if (this.isActive !== isActive) { + this.isActive = isActive + view.update([]) + } + }, + } + }, + { + eventHandlers: { + mousedown(event, view) { + if (timer) { + window.clearTimeout(timer) + } + // single click with the main mouse button + if (event.detail === 1 && event.button === 0) { + timer = window.setTimeout(() => { + timer = undefined + if (isInPrimarySelection(event, view)) { + this.set(true) + } + }, 50) + } + }, + mouseup() { + if (timer) { + window.clearTimeout(timer) + } + this.set(false) + }, + dragstart() { + if (timer) { + window.clearTimeout(timer) + } + this.set(false) + }, + }, + } + ) + + return [ + plugin, + EditorView.contentAttributes.of(view => + view.plugin(plugin)?.isActive ? showDraggable : null + ), + ] +} diff --git a/services/web/frontend/js/features/source-editor/extensions/index.ts b/services/web/frontend/js/features/source-editor/extensions/index.ts index 2f9a017039..7f6b01022c 100644 --- a/services/web/frontend/js/features/source-editor/extensions/index.ts +++ b/services/web/frontend/js/features/source-editor/extensions/index.ts @@ -46,6 +46,7 @@ import { shortcuts } from './shortcuts' import { effectListeners } from './effect-listeners' import { highlightSpecialChars } from './highlight-special-chars' import { toolbarPanel } from './toolbar/toolbar-panel' +import { draggableCursor } from './draggable-cursor' import { geometryChangeEvent } from './geometry-change-event' import { isSplitTestEnabled } from '../../../utils/splitTestUtils' @@ -78,6 +79,8 @@ export const createExtensions = (options: Record): Extension[] => [ indentationMarkers(options.visual.visual), bracketMatching(), bracketSelection(), + // NOTE: `draggableCursor` needs to be before `crosshairCursor`, so it takes precedence when Alt is held down. + draggableCursor(), // A built-in extension that enables rectangular selections, created by dragging a new selection while holding down Alt. rectangularSelection(), // A built-in extension that turns the pointer into a crosshair while Alt is pressed. diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/utils/selection.ts b/services/web/frontend/js/features/source-editor/extensions/visual/utils/selection.ts new file mode 100644 index 0000000000..71dfedd921 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/utils/selection.ts @@ -0,0 +1,29 @@ +/** + * Adapted from the "isInPrimarySelection" function in CodeMirror 6, licensed under the MIT license: + * https://github.com/codemirror/view/blob/main/src/input.ts + */ + +import { EditorView } from '@codemirror/view' + +export function isInPrimarySelection( + event: MouseEvent | undefined, + view?: EditorView +) { + if (!event) return false + if (view?.state.selection.main.empty) return false + + const selection = document.getSelection() + if (!selection || selection.rangeCount === 0) return true + + const rects = selection.getRangeAt(0).getClientRects() + for (const rect of rects) { + if ( + rect.left <= event.clientX && + rect.right >= event.clientX && + rect.top <= event.clientY && + rect.bottom >= event.clientY + ) + return true + } + return false +}