Skip to content

Instantly share code, notes, and snippets.

@ColinTimBarndt
Last active June 2, 2021 14:49
Show Gist options
  • Save ColinTimBarndt/72ef9256b6bcf0f87285e3209226ac82 to your computer and use it in GitHub Desktop.
Save ColinTimBarndt/72ef9256b6bcf0f87285e3209226ac82 to your computer and use it in GitHub Desktop.
A very dirty CodeMirror 6 hack to manually update the selection when a key is pressed
/**
* Based on [@codemirror/rectangular-selection](https://github.com/codemirror/rectangular-selection)
* This is a clean implementation made possible by [this commit]
* [this commit]: https://github.com/codemirror/view/commit/6ec0d746ff3a33e3f73870fa7527779ec7af9904
* @license MIT
*/
import { Extension, EditorSelection, EditorState, SelectionRange, Facet, Compartment } from "@codemirror/state";
import { EditorView, MouseSelectionStyle, PluginValue, ViewPlugin, ViewUpdate } from "@codemirror/view";
import { countColumn, findColumn } from "@codemirror/text";
type Pos = { line: number, col: number, off: number };
// Don't compute precise column positions for line offsets above this
// (since it could get expensive). Assume offset==column for them.
const MaxOff = 2000;
const altPressed = Facet.define<boolean, boolean>({
combine: value => value.some(v => v)
});
const comp = new Compartment();
function rectangleFor(state: EditorState, a: Pos, b: Pos): SelectionRange[] {
const reversed = a.line > b.line;
const startLine = reversed ? b.line : a.line;
const endLine = reversed ? a.line : b.line;
const ranges = [];
let i;
if (a.off > MaxOff || b.off > MaxOff || a.col < 0 || b.col < 0) {
const startOff = Math.min(a.off, b.off);
const endOff = Math.max(a.off, b.off);
for (i = startLine; i <= endLine; i++) {
const line = state.doc.line(i);
if (line.length <= endOff)
ranges.push(
reversed
? EditorSelection.range(line.to + endOff, line.from + startOff)
: EditorSelection.range(line.from + startOff, line.to + endOff)
);
}
} else {
const startCol = Math.min(a.col, b.col);
const endCol = Math.max(a.col, b.col);
for (i = startLine; i <= endLine; i++) {
const line = state.doc.line(i);
const str = line.length > MaxOff ? line.text.slice(0, 2 * endCol) : line.text;
const start = findColumn(str, 0, startCol, state.tabSize)
const end = findColumn(str, 0, endCol, state.tabSize);
if (!start.leftOver)
ranges.push(
reversed
? EditorSelection.range(line.from + end.offset, line.from + start.offset)
: EditorSelection.range(line.from + start.offset, line.from + end.offset)
);
}
}
return ranges;
}
function rangeFor(state: EditorState, a: Pos, b: Pos): SelectionRange[] {
const offA = state.doc.line(a.line).from + a.off;
const offB = state.doc.line(b.line).from + b.off;
return [
EditorSelection.range(offA, offB)
];
}
function absoluteColumn(view: EditorView, x: number): number {
let ref = view.coordsAtPos(view.viewport.from);
return ref ? Math.round(Math.abs((ref.left - x) / view.defaultCharacterWidth)) : -1;
}
function getPos(view: EditorView, event: MouseEvent): Pos | null {
let offset = view.posAtCoords({ x: event.clientX, y: event.clientY });
if (offset == null) return null;
let line = view.state.doc.lineAt(offset), off = offset - line.from;
let col = off > MaxOff ? -1
: off == line.length ? absoluteColumn(view, event.clientX)
: countColumn(line.text.slice(0, offset - line.from), 0, view.state.tabSize);
return { line: line.number, col, off };
}
function rectangleSelectionStyle(view: EditorView, event: MouseEvent): MouseSelectionStyle | null {
let start = getPos(view, event)!;
let startSel = view.state.selection;
let cur: Pos | null;
if (!start) return null;
return {
update(update) {
if (update.docChanged) {
const newStart = update.changes.mapPos(update.startState.doc.line(start.line).from);
const newLine = update.state.doc.lineAt(newStart);
start = { line: newLine.number, col: start.col, off: Math.min(start.off, newLine.length) };
startSel = startSel.map(update.changes);
return true;
} else if (update.startState.facet(altPressed) !== update.state.facet(altPressed)) {
return true;
}
return false;
},
get(event, _extend, multiple) {
const rectangular = view.state.facet(altPressed);
cur = getPos(view, event);
if (!cur) return startSel;
const ranges = (rectangular ? rectangleFor : rangeFor)(view.state, start, cur);
if (!ranges.length) return startSel;
if (multiple) return EditorSelection.create(ranges.concat(startSel.ranges));
else return EditorSelection.create(ranges);
}
} as MouseSelectionStyle
}
export function rectangularSelection(): Extension {
return [
EditorView.mouseSelectionStyle.of((view, event) =>
rectangleSelectionStyle(view, event)
),
listenAltKey(),
comp.of(altPressed.of(false))
];
}
function listenAltKey(): Extension {
return ViewPlugin.fromClass(AltKeyListener, {
eventHandlers: {
keyup(event, view) {
if (event.key === "Alt") {
event.preventDefault();
this.altPressed = false;
view.dispatch(
view.state.update({
effects: comp.reconfigure(altPressed.of(false)),
})
);
}
},
keydown(event, view) {
if (event.key === "Alt" && !this.altPressed) {
this.altPressed = true;
view.dispatch(
view.state.update({
effects: comp.reconfigure(altPressed.of(true)),
})
);
}
}
}
});
}
class AltKeyListener implements PluginValue {
altPressed: boolean = false;
constructor(private view: EditorView) { }
update(_: ViewUpdate) { }
destroy() { }
}
/**
* Based on [@codemirror/rectangular-selection](https://github.com/codemirror/rectangular-selection)
* @license MIT
*/
import { Extension, EditorSelection, EditorState, SelectionRange, Facet, Compartment } from "@codemirror/state";
import { EditorView, MouseSelectionStyle, PluginValue, ViewPlugin, ViewUpdate } from "@codemirror/view";
import { countColumn, findColumn } from "@codemirror/text";
type Pos = { line: number, col: number, off: number };
// Don't compute precise column positions for line offsets above this
// (since it could get expensive). Assume offset==column for them.
const MaxOff = 2000;
const altPressed = Facet.define<boolean, boolean>({
combine: value => value.some(v => v)
});
const comp = new Compartment();
function rectangleFor(state: EditorState, a: Pos, b: Pos): SelectionRange[] {
const reversed = a.line > b.line;
const startLine = reversed ? b.line : a.line;
const endLine = reversed ? a.line : b.line;
const ranges = [];
let i;
if (a.off > MaxOff || b.off > MaxOff || a.col < 0 || b.col < 0) {
const startOff = Math.min(a.off, b.off);
const endOff = Math.max(a.off, b.off);
for (i = startLine; i <= endLine; i++) {
const line = state.doc.line(i);
if (line.length <= endOff)
ranges.push(
reversed
? EditorSelection.range(line.to + endOff, line.from + startOff)
: EditorSelection.range(line.from + startOff, line.to + endOff)
);
}
} else {
const startCol = Math.min(a.col, b.col);
const endCol = Math.max(a.col, b.col);
for (i = startLine; i <= endLine; i++) {
const line = state.doc.line(i);
const str = line.length > MaxOff ? line.text.slice(0, 2 * endCol) : line.text;
const start = findColumn(str, 0, startCol, state.tabSize)
const end = findColumn(str, 0, endCol, state.tabSize);
if (!start.leftOver)
ranges.push(
reversed
? EditorSelection.range(line.from + end.offset, line.from + start.offset)
: EditorSelection.range(line.from + start.offset, line.from + end.offset)
);
}
}
return ranges;
}
function rangeFor(state: EditorState, a: Pos, b: Pos): SelectionRange[] {
const offA = state.doc.line(a.line).from + a.off;
const offB = state.doc.line(b.line).from + b.off;
return [
EditorSelection.range(offA, offB)
];
}
function absoluteColumn(view: EditorView, x: number): number {
let ref = view.coordsAtPos(view.viewport.from);
return ref ? Math.round(Math.abs((ref.left - x) / view.defaultCharacterWidth)) : -1;
}
function getPos(view: EditorView, event: MouseEvent): Pos | null {
let offset = view.posAtCoords({ x: event.clientX, y: event.clientY });
if (offset == null) return null;
let line = view.state.doc.lineAt(offset), off = offset - line.from;
let col = off > MaxOff ? -1
: off == line.length ? absoluteColumn(view, event.clientX)
: countColumn(line.text.slice(0, offset - line.from), 0, view.state.tabSize);
return { line: line.number, col, off };
}
function rectangleSelectionStyle(view: EditorView, event: MouseEvent): MouseSelectionStyle | null {
let start = getPos(view, event)!;
let startSel = view.state.selection;
let cur: Pos | null;
if (!start) return null;
return {
update(update) {
if (update.docChanged) {
const newStart = update.changes.mapPos(update.startState.doc.line(start.line).from);
const newLine = update.state.doc.lineAt(newStart);
start = { line: newLine.number, col: start.col, off: Math.min(start.off, newLine.length) };
startSel = startSel.map(update.changes);
}
},
get(event, _extend, multiple) {
const rectangular = view.state.facet(altPressed);
cur = getPos(view, event);
if (!cur) return startSel;
const ranges = (rectangular ? rectangleFor : rangeFor)(view.state, start, cur);
if (!ranges.length) return startSel;
if (multiple) return EditorSelection.create(ranges.concat(startSel.ranges));
else return EditorSelection.create(ranges);
}
} as MouseSelectionStyle
}
export function rectangularSelection(): Extension {
return [
EditorView.mouseSelectionStyle.of((view, event) =>
rectangleSelectionStyle(view, event)
),
listenAltKey(),
comp.of(altPressed.of(false))
];
}
function listenAltKey(): Extension {
return ViewPlugin.fromClass(AltKeyListener, {
eventHandlers: {
keyup(event, view) {
if (event.key === "Alt") {
event.preventDefault();
view.dispatch(
view.state.update({
effects: comp.reconfigure(altPressed.of(false)),
})
);
this.updateSelection();
}
},
keydown(event, view) {
if (event.key === "Alt") {
view.dispatch(
view.state.update({
effects: comp.reconfigure(altPressed.of(true)),
})
);
this.updateSelection();
}
},
mousemove(event, _view) {
this.lastMouseMove = event;
}
}
});
}
class AltKeyListener implements PluginValue {
lastMouseMove: MouseEvent | null = null;
constructor(private view: EditorView) { }
update(_: ViewUpdate) { }
destroy() { }
/**
* HACK to force-update the selection
*/
updateSelection() {
let inputState: any = (this.view as any).inputState;
let selection: any = inputState.mouseSelection;
if (selection != null && this.lastMouseMove != null) {
const { lastMouseMove: event } = this;
if (event.buttons == 0) return;
// Run after the keyboard event is over
setTimeout(() => selection.select(event), 0);
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment