Last active
July 23, 2022 13:01
-
-
Save s-cork/898cd7c21e55a4963132cb9d9e2c6615 to your computer and use it in GitHub Desktop.
Codemirror 6 indentation guides
This file contains 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 { RangeSetBuilder } from "@codemirror/state"; | |
import { Extension } from "@codemirror/state"; | |
import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate, WidgetType } from "@codemirror/view"; | |
export function indentationGuides(): Extension { | |
return [ViewPlugin.fromClass(IndentationGuides, { decorations: (v) => v.decorations }), indentationTheme]; | |
} | |
// markers can be used at positions on a line over a range | |
const indentationMark = Decoration.mark({ class: "cm-indentation-guide" }); | |
// widget are used for space only lines when we need to fill | |
// the indentation guides on parts of the line that don't exist | |
class IndentationWidget extends WidgetType { | |
constructor(readonly indents: number[]) { | |
super(); | |
} | |
static create(indents: number[]) { | |
return Decoration.widget({ | |
widget: new IndentationWidget(indents), | |
side: 1, | |
}); | |
} | |
toDOM() { | |
const wrap = document.createElement("span"); | |
wrap.className = "cm-indentation-widget"; | |
for (const indent of this.indents) { | |
const marker = wrap.appendChild(document.createElement("span")); | |
marker.className = "cm-indentation-guide"; | |
marker.textContent = " ".repeat(indent); | |
} | |
return wrap; | |
} | |
} | |
const SPACES = /^\s*/; | |
function getCodeStart(text: string) { | |
return text.match(SPACES)![0].length; | |
} | |
function makeIndentationMark( | |
from: number, | |
to: number, | |
totalIndent: number, | |
tabSize: number, | |
builder: RangeSetBuilder<Decoration> | |
) { | |
let indentPos = from + tabSize; | |
const toMark = to - from; | |
const upTo = Math.min(to - (toMark % tabSize), from + totalIndent); | |
while (indentPos <= upTo) { | |
builder.add(indentPos - 1, indentPos, indentationMark); | |
indentPos += tabSize; | |
} | |
} | |
function makeIndentationWidget( | |
from: number, | |
to: number, | |
totalIndent: number, | |
tabSize: number, | |
builder: RangeSetBuilder<Decoration> | |
) { | |
const alreadyMarked = to - from; | |
if (totalIndent <= alreadyMarked) { | |
// we only add widgets when the line length is less than the indentation guide | |
// if the indent <= length we just use the indentationMark | |
return; | |
} | |
const indentsToMark: number[] = []; | |
const toMark = totalIndent - alreadyMarked; | |
// e.g. if tabSize=4 and toMark = 5, then we have to add a single space to complete the alreadyMarked indents | |
const indentationMarkToComplete = toMark % tabSize; | |
if (indentationMarkToComplete) { | |
indentsToMark.push(indentationMarkToComplete); | |
} | |
const numRemainingIndents = (toMark - indentationMarkToComplete) / tabSize; | |
indentsToMark.push(...Array(numRemainingIndents).fill(tabSize)); | |
builder.add(to, to, IndentationWidget.create(indentsToMark)); | |
} | |
function makeIndentationDecorators(view: EditorView) { | |
const builder = new RangeSetBuilder<Decoration>(); | |
const tabSize = Number(view.state.tabSize); | |
const doc = view.state.doc; | |
const spaceOnlyLines = []; | |
let currentIndent = 0; | |
for (const { from: visibleFrom, to: visibleTo } of view.visibleRanges) { | |
let to = visibleFrom - 1; | |
let pos, from, length, text; | |
while ((pos = to + 1) <= visibleTo) { | |
({ from, to, length, text } = doc.lineAt(pos)); | |
const codeStartsAt = getCodeStart(text); | |
const isAllSpaces = codeStartsAt === length; | |
// we don't have indentation guides for the zeroth indentation level | |
const skipIndent = codeStartsAt < tabSize; | |
const isComment = text[codeStartsAt] == "#"; /** @todo for other languages */ | |
if (isAllSpaces) { | |
spaceOnlyLines.push({ from, to }); | |
continue; | |
} else if (skipIndent) { | |
spaceOnlyLines.length = 0; | |
continue; | |
} | |
// we have a valid indentation that needs guiding! | |
// get the current indent level | |
// fill all space only lines that we've kept track of | |
const indent = codeStartsAt - (codeStartsAt % tabSize); | |
if (!isComment) { | |
// we have a valid new currentIndent - use this value | |
currentIndent = indent; | |
} | |
for (const { from, to } of spaceOnlyLines) { | |
makeIndentationMark(from, to, currentIndent, tabSize, builder); | |
makeIndentationWidget(from, to, currentIndent, tabSize, builder); | |
} | |
spaceOnlyLines.length = 0; | |
makeIndentationMark(from, to, indent, tabSize, builder); | |
} | |
} | |
return builder.finish(); | |
} | |
class IndentationGuides { | |
decorations: DecorationSet; | |
constructor(view: EditorView) { | |
this.decorations = makeIndentationDecorators(view); | |
} | |
update(update: ViewUpdate) { | |
if (update.docChanged || update.viewportChanged) { | |
this.decorations = makeIndentationDecorators(update.view); | |
} | |
} | |
} | |
const indentationTheme = EditorView.baseTheme({ | |
".cm-indentation-widget": { | |
display: "inline-block", | |
}, | |
".cm-indentation-guide": { | |
position: "relative", | |
height: "100%", | |
display: "inline-block", | |
}, | |
".cm-indentation-guide:after": { | |
position: "absolute", | |
content: "''", | |
right: "2px", | |
height: "100%", | |
borderLeft: "1px solid rgba(193, 199, 249, 0.4)", | |
}, | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment