Last active
October 1, 2024 18:39
-
-
Save andy0130tw/628a483ef87284b5a0be3904fffe3861 to your computer and use it in GitHub Desktop.
Draggable gutter for selecting lines
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
StateField, | |
StateEffect, | |
EditorSelection, | |
StateEffectType, | |
} from '@codemirror/state' | |
import { EditorView, ViewPlugin } from '@codemirror/view' | |
/** @import { Text, Line } from '@codemirror/state' */ | |
/** @import { PluginValue, BlockInfo } from '@codemirror/view' */ | |
// the hacky way to steal a reference of active gutter | |
import { gutters } from '@codemirror/view' | |
const gutterView = /** @type {[ViewPlugin<PluginValue>]} */(gutters())[0] | |
// XXX: make it configurable? | |
const nav = typeof navigator != "undefined" ? navigator : {userAgent: "", vendor: "", platform: ""} | |
const mac = /Mac/.test(nav.platform) | |
/** | |
* @typedef DraggingState | |
* @property {number} pos | |
* @property {'shift' | undefined} [mode] | |
*/ | |
/** @type {StateEffectType<DraggingState>} */ | |
const setDraggingState = StateEffect.define() | |
const GUTTER_DRAG_END_EVENT = 'gutter.drag.end' | |
/** @param {EditorView} view */ | |
function gutterStopDrag(view) { | |
view.dispatch({ | |
effects: setDraggingState.of({ pos: -1 }), | |
userEvent: GUTTER_DRAG_END_EVENT, | |
}) | |
} | |
/** | |
* @param {Line | BlockInfo} _ | |
* @param {Text} doc */ | |
function nextLinePos({ from, length }, doc) { | |
return Math.min(from + length + 1, doc.length) | |
} | |
/** | |
* @param {EditorView} view | |
* @param {BlockInfo} line | |
* @param {Event} _evt */ | |
function mousedownHandler(view, line, _evt) { | |
const { state } = view | |
const evt = /** @type {MouseEvent} */ (_evt) | |
const anchor = line.from | |
let selection | |
let mode | |
if (evt.shiftKey && !evt.ctrlKey && !evt.metaKey) { | |
// shift-click to extend the main range | |
selection = state.selection.replaceRange( | |
EditorSelection.range(state.selection.main.anchor, anchor)) | |
mode = /** @type {const} */('shift') | |
} else { | |
// cmd/ctrl-click to add a range | |
const isCtrl = (mac && evt.metaKey) || (!mac && evt.ctrlKey) | |
const head = nextLinePos(line, state.doc) | |
selection = isCtrl ? | |
state.selection.addRange(EditorSelection.range(anchor, head)) : EditorSelection.single(anchor, head) | |
} | |
view.dispatch({ | |
effects: setDraggingState.of({ mode, pos: anchor }), | |
selection, | |
}) | |
view.focus() | |
return true | |
} | |
/** | |
* @param {EditorView} view | |
* @param {BlockInfo | null} line the line the cursor is currently at | |
* @param {Event} _evt */ | |
function mousemoveHandler(view, line, _evt) { | |
const { state } = view | |
const evt = /** @type {MouseEvent} */ (_evt) | |
const dstate = state.field(gutterDraggingState) | |
const selBeginPos = dstate.pos | |
if (selBeginPos < 0) { | |
return false | |
} | |
if (evt.buttons === 0) { | |
gutterStopDrag(view) | |
return true | |
} | |
// if the event is not passing in a line, | |
// infer from the mouse position | |
if (line == null) { | |
if ('layerY' in evt) { | |
line = view.elementAtHeight(evt.layerY) | |
} else return false | |
} | |
const lineAnc = state.doc.lineAt(selBeginPos) | |
let anchor, head | |
if (dstate.mode == 'shift') { | |
anchor = state.selection.main.anchor | |
head = line.from | |
} else { | |
anchor = lineAnc.from | |
head = line.from | |
if (anchor == head) { | |
head = nextLinePos(lineAnc, state.doc) | |
} else if (anchor > head) { | |
// dragging upward; place the anchor at the last line | |
anchor = nextLinePos(lineAnc, state.doc) | |
} | |
} | |
view.dispatch({ | |
selection: state.selection.replaceRange(EditorSelection.range(anchor, head)), | |
}) | |
return true | |
} | |
/** @param {EditorView} view */ | |
function mouseupHandler(view) { | |
gutterStopDrag(view) | |
return true | |
} | |
// the beginning position of dragging, usually anchor | |
const gutterDraggingState = StateField.define({ | |
/** @returns {DraggingState} */ | |
create() { | |
return { pos: -1 } | |
}, | |
update(value, tr) { | |
for (const e of tr.effects) { | |
if (e.is(setDraggingState)) { | |
value = e.value | |
} | |
} | |
return value | |
}, | |
}) | |
const draggableGutterView = ViewPlugin.fromClass(class DraggableGutter { | |
/** @param {EditorView} view */ | |
constructor(view) { | |
this.view = view | |
const pluginValue = view.plugin(gutterView) | |
if (pluginValue == null) return | |
/** @type {HTMLElement} */ | |
this.dom = /** @type {any} */(pluginValue).dom | |
this.domHandlers = { | |
mousedown: this.wrapDomHandler(mousedownHandler), | |
mousemove: this.wrapDomHandler(mousemoveHandler), | |
mouseup: this.wrapDomHandler(mouseupHandler), | |
} | |
this.unbindEventListeners = this.bindEventListeners() | |
this.destroy = this.unbindEventListeners | |
this.docViewUpdate(view) | |
} | |
/** @param {EditorView} view */ | |
docViewUpdate(view) { | |
// grab a reference to get the block info | |
this.refDom = view.dom.querySelector('.cm-gutter') | |
} | |
bindEventListeners() { | |
if (this.domHandlers == null) return | |
const props = Object.keys(this.domHandlers) | |
for (const _p of props) { | |
const prop = /** @type {keyof typeof this.domHandlers} */ (_p) | |
this.dom.addEventListener(prop, this.domHandlers[prop]) | |
} | |
return () => { | |
for (const _p of props) { | |
const prop = /** @type {keyof typeof this.domHandlers} */ (_p) | |
this.dom.removeEventListener(prop, this.domHandlers[prop]) | |
} | |
} | |
} | |
/** @param {(view: EditorView, line: BlockInfo, event: Event) => boolean} inner */ | |
wrapDomHandler(inner) { | |
// shamelessly modified from CM's SingleGutterView | |
return (/** @type {Event} */ event) => { | |
let target = /** @type {HTMLElement} */(event.target), y | |
// do not trigger on children in gutter elements; | |
// they might be interactable | |
if (this.refDom == null || | |
!target.matches('.cm-gutter, .cm-gutterElement')) | |
return | |
if (target != this.refDom && this.refDom.contains(target)) { | |
while (target.parentNode != this.refDom) | |
target = /** @type {HTMLElement} */ (target.parentNode) | |
let rect = target.getBoundingClientRect() | |
y = (rect.top + rect.bottom) / 2 | |
} else { | |
y = /** @type {MouseEvent} */(event).clientY | |
} | |
let line = this.view.lineBlockAtHeight(y - this.view.documentTop) | |
if (inner(this.view, line, event)) { | |
event.preventDefault() | |
} | |
} | |
} | |
}) | |
export function draggableGutter() { | |
return [ | |
gutterDraggingState, | |
draggableGutterView, | |
EditorView.domEventHandlers({ | |
mousemove(evt, view) { | |
return mousemoveHandler(view, null, evt) | |
}, | |
}), | |
] | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment