|
|
|
|
|
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 |
|
}) |
|
]; |
|
} |
|
|