| |
| |
| 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'; |
|
|
| |
| |
| |
| |
| |
| |
| export function getVisibleLines(view: EditorView, state = view.state): Set<Line> { |
| const lines = new Set<Line>(); |
|
|
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| export function getCurrentLine(state: EditorState): Line { |
| const currentPos = state.selection.main.head; |
| return state.doc.lineAt(currentPos); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function numColumns(str: string, tabSize: number): number { |
| |
| |
| |
|
|
| let col = 0; |
|
|
| |
| loop: for (let i = 0; i < str.length; i++) { |
| switch (str[i]) { |
| case ' ': { |
| col += 1; |
| continue loop; |
| } |
|
|
| case '\t': { |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export class IndentationMap { |
| |
| private state: EditorState; |
|
|
| |
| private lines: Set<Line>; |
|
|
| |
| private map: Map<number, IndentEntry>; |
|
|
| |
| private unitWidth: number; |
|
|
| |
| |
| |
| |
| |
| constructor(lines: Set<Line>, 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(); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| has(line: Line | number): boolean { |
| return this.map.has(typeof line === 'number' ? line : line.number); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| private add(line: Line) { |
| if (this.has(line)) { |
| return this.get(line); |
| } |
|
|
| |
| if (!line.length || !line.text.trim().length) { |
| |
| if (line.number === 1) { |
| return this.set(line, 0, 0); |
| } |
|
|
| |
| 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 (prev.level >= next.level) { |
| return this.set(line, 0, prev.level); |
| } |
|
|
| |
| if (prev.empty && prev.level === 0 && next.level !== 0) { |
| return this.set(line, 0, 0); |
| } |
|
|
| |
| |
| |
| if (next.level > prev.level) { |
| return this.set(line, 0, prev.level + 1); |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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; |
| } |
| } |
|
|
| |
| |
| |
|
|
| 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; |
| } |
|
|
| |
| |
| |
|
|
| const line = this.state.doc.line(dir === -1 ? 1 : this.state.doc.lines); |
|
|
| return this.set(line, 0, 0); |
| } |
|
|
| |
| |
| |
| |
| private findAndSetActiveLines() { |
| const currentLine = getCurrentLine(this.state); |
|
|
| if (!this.has(currentLine)) { |
| return; |
| } |
|
|
| let current = this.get(currentLine); |
|
|
| |
| |
| if (this.has(current.line.number + 1)) { |
| const next = this.get(current.line.number + 1); |
| if (next.level > current.level) { |
| current = next; |
| } |
| } |
|
|
| |
| 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; |
|
|
| |
| 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; |
| } |
|
|
| |
| 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; |
| } |
| } |
| } |
|
|
| |
| |
|
|
| |
| |
| |
|
|
| |
| const MARKER_COLOR_LIGHT = '#F0F1F2'; |
| const MARKER_COLOR_DARK = '#2B3245'; |
|
|
| |
| const MARKER_COLOR_ACTIVE_LIGHT = '#E4E5E6'; |
| const MARKER_COLOR_ACTIVE_DARK = '#3C445C'; |
|
|
| |
| 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' |
| }, |
|
|
| |
| |
| '.cm-indent-markers::before': { |
| content: '""', |
| position: 'absolute', |
| top: 0, |
| left: '2px', |
| right: 0, |
| bottom: 0, |
| background: 'var(--indent-markers)', |
| pointerEvents: 'none' |
| |
| } |
| }); |
|
|
| 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)`; |
| |
| 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 { |
| |
| |
| |
| highlightActiveBlock?: boolean; |
|
|
| |
| |
| |
| hideFirstIndent?: boolean; |
| } |
|
|
| export const indentationMarkerConfig = Facet.define< |
| IndentationMarkerConfiguration, |
| Required<IndentationMarkerConfiguration> |
| >({ |
| 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<Decoration>(); |
|
|
| 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 |
| }) |
| ]; |
| } |
|
|