// original: https://github.com/replit/codemirror-indentation-markers/tree/f2f8ccdc58fcc51dbe6a82784f2f83fa6ac4648c/src/ // combined all 3 files (index.ts, map.ts, utils.ts) from original source into this one file import { getIndentUnit } from '@codemirror/language'; import type { EditorState, Extension, Line } from '@codemirror/state'; import { combineConfig, Facet, RangeSetBuilder } from '@codemirror/state'; import type { DecorationSet, ViewUpdate, PluginValue } from '@codemirror/view'; import { Decoration, ViewPlugin, EditorView } from '@codemirror/view'; /** * Gets the visible lines in the editor. Lines will not be repeated. * * @param view - The editor view to get the visible lines from. * @param state - The editor state. Defaults to the view's current one. */ export function getVisibleLines(view: EditorView, state = view.state): Set { const lines = new Set(); for (const { from, to } of view.visibleRanges) { let pos = from; while (pos <= to) { const line = state.doc.lineAt(pos); if (!lines.has(line)) { lines.add(line); } pos = line.to + 1; } } return lines; } /** * Gets the line at the position of the primary cursor. * * @param state - The editor state from which to extract the line. */ export function getCurrentLine(state: EditorState): Line { const currentPos = state.selection.main.head; return state.doc.lineAt(currentPos); } /** * Returns the number of columns that a string is indented, controlling for * tabs. This is useful for determining the indentation level of a line. * * Note that this only returns the number of _visible_ columns, not the number * of whitespace characters at the start of the string. * * @param str - The string to check. * @param tabSize - The size of a tab character. Usually 2 or 4. */ export function numColumns(str: string, tabSize: number): number { // as far as I can tell, this is pretty much the fastest way to do this, // at least involving iteration. `str.length - str.trimStart().length` is // much faster, but it has some edge cases that are hard to deal with. let col = 0; // eslint-disable-next-line no-restricted-syntax, @typescript-eslint/prefer-for-of loop: for (let i = 0; i < str.length; i++) { switch (str[i]) { case ' ': { col += 1; continue loop; } case '\t': { // if the current column is a multiple of the tab size, we can just // add the tab size to the column. otherwise, we need to add the // difference between the tab size and the current column. col += tabSize - (col % tabSize); continue loop; } case '\r': { continue loop; } default: { break loop; } } } return col; } export interface IndentEntry { line: Line; col: number; level: number; empty: boolean; active?: number; } /** * Indentation map for a set of lines. * * This map will contain the indentation for lines that are not a part of the given set, * but this is because calculating the indentation for those lines was necessary to * calculate the indentation for the lines provided to the constructor. * * @see {@link IndentEntry} */ export class IndentationMap { /** The {@link EditorState} indentation is derived from. */ private state: EditorState; /** The set of lines that are used as an entrypoint. */ private lines: Set; /** The internal mapping of line numbers to {@link IndentEntry} objects. */ private map: Map; /** The width of the editor's indent unit. */ private unitWidth: number; /** * @param lines - The set of lines to get the indentation map for. * @param state - The {@link EditorState} to derive the indentation map from. * @param unitWidth - The width of the editor's indent unit. */ constructor(lines: Set, state: EditorState, unitWidth: number) { this.lines = lines; this.state = state; this.map = new Map(); this.unitWidth = unitWidth; for (const line of this.lines) { this.add(line); } if (this.state.facet(indentationMarkerConfig).highlightActiveBlock) { this.findAndSetActiveLines(); } } /** * Checks if the indentation map has an entry for the given line. * * @param line - The {@link Line} or line number to check for. */ has(line: Line | number): boolean { return this.map.has(typeof line === 'number' ? line : line.number); } /** * Returns the {@link IndentEntry} for the given line. * * Note that this function will throw an error if the line does not exist in the map. * * @param line - The {@link Line} or line number to get the entry for. */ get(line: Line | number): IndentEntry { const entry = this.map.get(typeof line === 'number' ? line : line.number); if (!entry) { throw new Error('Line not found in indentation map'); } return entry; } /** * Sets the {@link IndentEntry} for the given line. * * @param line - The {@link Line} to set the entry for. * @param col - The visual beginning whitespace width of the line. * @param level - The indentation level of the line. */ private set(line: Line, col: number, level: number) { const empty = !line.text.trim().length; const entry: IndentEntry = { line, col, level, empty }; this.map.set(entry.line.number, entry); return entry; } /** * Adds a line to the indentation map. * * @param line - The {@link Line} to add to the map. */ private add(line: Line) { if (this.has(line)) { return this.get(line); } // empty lines continue their indentation from surrounding lines if (!line.length || !line.text.trim().length) { // the very first line, if empty, is just ignored and set as a 0 indent level if (line.number === 1) { return this.set(line, 0, 0); } // if we're at the end, we'll just use the previous line's indentation if (line.number === this.state.doc.lines) { const prev = this.closestNonEmpty(line, -1); return this.set(line, 0, prev.level); } const prev = this.closestNonEmpty(line, -1); const next = this.closestNonEmpty(line, 1); // if the next line ends the block, we'll use the previous line's indentation if (prev.level >= next.level) { return this.set(line, 0, prev.level); } // having an indent marker that starts from an empty line looks weird if (prev.empty && prev.level === 0 && next.level !== 0) { return this.set(line, 0, 0); } // if the next indentation level is greater than the previous, // we'll only increment up to the next indentation level. this prevents // a weirdly "backwards propagating" indentation. if (next.level > prev.level) { return this.set(line, 0, prev.level + 1); } // else, we default to the next line's indentation return this.set(line, 0, next.level); } const col = numColumns(line.text, this.state.tabSize); const level = Math.floor(col / this.unitWidth); return this.set(line, col, level); } /** * Finds the closest non-empty line, starting from the given line. * * @param from - The {@link Line} to start from. * @param dir - The direction to search in. Either `1` or `-1`. */ private closestNonEmpty(from: Line, dir: -1 | 1) { let lineNo = from.number + dir; while (dir === -1 ? lineNo >= 1 : lineNo <= this.state.doc.lines) { if (this.has(lineNo)) { const entry = this.get(lineNo); if (!entry.empty) { return entry; } } // we can check if the line is empty, if it's not, we can // just create a new entry for it and return it. // this prevents us from hitting the beginning/end of the document unnecessarily. const line = this.state.doc.line(lineNo); if (line.text.trim().length) { const col = numColumns(line.text, this.state.tabSize); const level = Math.floor(col / this.unitWidth); return this.set(line, col, level); } lineNo += dir; } // if we're here, we didn't find anything. // that means we're at the beginning/end of the document, // and the first/last line is empty. const line = this.state.doc.line(dir === -1 ? 1 : this.state.doc.lines); return this.set(line, 0, 0); } /** * Finds the state's active block (via the current selection) and sets all * the active indent level for the lines in the block. */ private findAndSetActiveLines() { const currentLine = getCurrentLine(this.state); if (!this.has(currentLine)) { return; } let current = this.get(currentLine); // check if the current line is starting a new block, if yes, we want to // start from inside the block. if (this.has(current.line.number + 1)) { const next = this.get(current.line.number + 1); if (next.level > current.level) { current = next; } } // same, but if the current line is ending a block if (this.has(current.line.number - 1)) { const prev = this.get(current.line.number - 1); if (prev.level > current.level) { current = prev; } } if (current.level === 0) { return; } current.active = current.level; let start: number; let end: number; // iterate to the start of the block for (start = current.line.number; start > 1; start--) { if (!this.has(start - 1)) { continue; } const prev = this.get(start - 1); if (prev.level < current.level) { break; } prev.active = current.level; } // iterate to the end of the block for (end = current.line.number; end < this.state.doc.lines; end++) { if (!this.has(end + 1)) { continue; } const next = this.get(end + 1); if (next.level < current.level) { break; } next.active = current.level; } } } // CSS classes: // - .cm-indent-markers // CSS variables: // - --indent-marker-bg-part // - --indent-marker-active-bg-part /** Color of inactive indent markers. Based on RUI's var(--background-higher) */ const MARKER_COLOR_LIGHT = '#F0F1F2'; const MARKER_COLOR_DARK = '#2B3245'; /** Color of active indent markers. Based on RUI's var(--background-highest) */ const MARKER_COLOR_ACTIVE_LIGHT = '#E4E5E6'; const MARKER_COLOR_ACTIVE_DARK = '#3C445C'; /** Thickness of indent markers. Probably should be integer pixel values. */ const MARKER_THICKNESS = '1px'; const indentTheme = EditorView.baseTheme({ '&light': { '--indent-marker-bg-color': MARKER_COLOR_LIGHT, '--indent-marker-active-bg-color': MARKER_COLOR_ACTIVE_LIGHT }, '&dark': { '--indent-marker-bg-color': MARKER_COLOR_DARK, '--indent-marker-active-bg-color': MARKER_COLOR_ACTIVE_DARK }, '.cm-line': { position: 'relative' }, // this pseudo-element is used to draw the indent markers, // while still allowing the line to have its own background. '.cm-indent-markers::before': { content: '""', position: 'absolute', top: 0, left: '2px', right: 0, bottom: 0, background: 'var(--indent-markers)', pointerEvents: 'none' // zIndex: '-1', } }); function createGradient( markerCssProperty: string, indentWidth: number, startOffset: number, columns: number ) { const gradient = `repeating-linear-gradient(to right, var(${markerCssProperty}) 0 ${MARKER_THICKNESS}, transparent ${MARKER_THICKNESS} ${indentWidth}ch)`; // Subtract one pixel from the background width to get rid of artifacts of pixel rounding return `${gradient} ${startOffset * indentWidth}.5ch/calc(${indentWidth * columns}ch - 1px) no-repeat`; } function makeBackgroundCSS( entry: IndentEntry, indentWidth: number, hideFirstIndent: boolean ): string { const { level, active } = entry; if (hideFirstIndent && level === 0) { return ''; } const startAt = hideFirstIndent ? 1 : 0; const backgrounds: string[] = []; if (active !== undefined) { const markersBeforeActive = active - startAt - 1; if (markersBeforeActive > 0) { backgrounds.push( createGradient('--indent-marker-bg-color', indentWidth, startAt, markersBeforeActive) ); } backgrounds.push(createGradient('--indent-marker-active-bg-color', indentWidth, active - 1, 1)); if (active !== level) { backgrounds.push( createGradient('--indent-marker-bg-color', indentWidth, active, level - active) ); } } else { backgrounds.push( createGradient('--indent-marker-bg-color', indentWidth, startAt, level - startAt) ); } return backgrounds.join(','); } interface IndentationMarkerConfiguration { /** * Determines whether active block marker is styled differently. */ highlightActiveBlock?: boolean; /** * Determines whether markers in the first column are omitted. */ hideFirstIndent?: boolean; } export const indentationMarkerConfig = Facet.define< IndentationMarkerConfiguration, Required >({ combine(configs) { return combineConfig(configs, { highlightActiveBlock: true, hideFirstIndent: false }); } }); class IndentMarkersClass implements PluginValue { view: EditorView; decorations!: DecorationSet; private unitWidth: number; private currentLineNumber: number; constructor(view: EditorView) { this.view = view; this.unitWidth = getIndentUnit(view.state); this.currentLineNumber = getCurrentLine(view.state).number; this.generate(view.state); } update(update: ViewUpdate) { const unitWidth = getIndentUnit(update.state); const unitWidthChanged = unitWidth !== this.unitWidth; if (unitWidthChanged) { this.unitWidth = unitWidth; } const lineNumber = getCurrentLine(update.state).number; const lineNumberChanged = lineNumber !== this.currentLineNumber; this.currentLineNumber = lineNumber; const activeBlockUpdateRequired = update.state.facet(indentationMarkerConfig).highlightActiveBlock && lineNumberChanged; if ( update.docChanged || update.viewportChanged || unitWidthChanged || activeBlockUpdateRequired ) { this.generate(update.state); } } private generate(state: EditorState) { const builder = new RangeSetBuilder(); const lines = getVisibleLines(this.view, state); const map = new IndentationMap(lines, state, this.unitWidth); const { hideFirstIndent } = state.facet(indentationMarkerConfig); for (const line of lines) { const entry = map.get(line.number); if (!entry?.level) { continue; } const backgrounds = makeBackgroundCSS(entry, this.unitWidth, hideFirstIndent); builder.add( line.from, line.from, Decoration.line({ class: 'cm-indent-markers', attributes: { style: `--indent-markers: ${backgrounds}` } }) ); } this.decorations = builder.finish(); } } export function indentationMarkers(config: IndentationMarkerConfiguration = {}): Extension { return [ indentationMarkerConfig.of(config), indentTheme, ViewPlugin.fromClass(IndentMarkersClass, { decorations: (v) => v.decorations }) ]; }