Skip to content

Instantly share code, notes, and snippets.

@andy0130tw
Last active October 1, 2024 18:39
Show Gist options
  • Save andy0130tw/628a483ef87284b5a0be3904fffe3861 to your computer and use it in GitHub Desktop.
Save andy0130tw/628a483ef87284b5a0be3904fffe3861 to your computer and use it in GitHub Desktop.
Draggable gutter for selecting lines
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