Skip to content

Instantly share code, notes, and snippets.

@s-cork
Last active July 23, 2022 13:01
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save s-cork/898cd7c21e55a4963132cb9d9e2c6615 to your computer and use it in GitHub Desktop.
Save s-cork/898cd7c21e55a4963132cb9d9e2c6615 to your computer and use it in GitHub Desktop.
Codemirror 6 indentation guides
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