1- import { syntaxTree } from "@codemirror/language" ;
1+ import { getIndentUnit , syntaxTree } from "@codemirror/language" ;
22import type { Extension } from "@codemirror/state" ;
33import { EditorState , RangeSetBuilder } from "@codemirror/state" ;
44import {
77 EditorView ,
88 ViewPlugin ,
99 type ViewUpdate ,
10- WidgetType ,
1110} from "@codemirror/view" ;
1211import type { SyntaxNode } from "@lezer/common" ;
1312
@@ -26,11 +25,23 @@ const defaultConfig: Required<IndentGuidesConfig> = {
2625 hideOnBlankLines : false ,
2726} ;
2827
28+ const GUIDE_MARK_CLASS = "cm-indent-guides" ;
29+
2930/**
3031 * Get the tab size from editor state
3132 */
3233function getTabSize ( state : EditorState ) : number {
33- return state . facet ( EditorState . tabSize ) ;
34+ const tabSize = state . facet ( EditorState . tabSize ) ;
35+ return Number . isFinite ( tabSize ) && tabSize > 0 ? tabSize : 4 ;
36+ }
37+
38+ /**
39+ * Resolve the indentation width used for guide spacing.
40+ */
41+ function getIndentUnitColumns ( state : EditorState ) : number {
42+ const width = getIndentUnit ( state ) ;
43+ if ( Number . isFinite ( width ) && width > 0 ) return width ;
44+ return getTabSize ( state ) ;
3445}
3546
3647/**
@@ -57,6 +68,21 @@ function isBlankLine(line: string): boolean {
5768 return / ^ \s * $ / . test ( line ) ;
5869}
5970
71+ /**
72+ * Count the leading indentation characters of a line.
73+ */
74+ function getLeadingWhitespaceLength ( line : string ) : number {
75+ let count = 0 ;
76+ for ( const ch of line ) {
77+ if ( ch === " " || ch === "\t" ) {
78+ count ++ ;
79+ continue ;
80+ }
81+ break ;
82+ }
83+ return count ;
84+ }
85+
6086/**
6187 * Node types that represent scope blocks in various languages
6288 */
@@ -83,7 +109,6 @@ const SCOPE_NODE_TYPES = new Set([
83109 "Element" ,
84110 "SelfClosingTag" ,
85111 "RuleSet" ,
86- "Block" ,
87112 "DeclarationList" ,
88113 "Body" ,
89114 "Suite" ,
@@ -114,15 +139,12 @@ function getActiveScope(
114139
115140 const tree = syntaxTree ( state ) ;
116141 if ( ! tree || tree . length === 0 ) {
117- // No syntax tree available, fall back to indentation-based
118142 return getActiveScopeByIndentation ( state , indentUnit ) ;
119143 }
120144
121- // Find the innermost scope node containing the cursor
122145 let scopeNode : SyntaxNode | null = null ;
123146 let node : SyntaxNode | null = tree . resolveInner ( cursorPos , 0 ) ;
124147
125- // Walk up the tree to find a scope-defining node
126148 while ( node ) {
127149 if ( SCOPE_NODE_TYPES . has ( node . name ) ) {
128150 scopeNode = node ;
@@ -135,12 +157,8 @@ function getActiveScope(
135157 return null ;
136158 }
137159
138- // Get the line range of this scope
139160 const startLine = state . doc . lineAt ( scopeNode . from ) ;
140161 const endLine = state . doc . lineAt ( scopeNode . to ) ;
141-
142- // Calculate indent level from the first line of the scope's content
143- // (usually the line after the opening bracket)
144162 let contentStartLine = startLine . number ;
145163 if ( startLine . number < endLine . number ) {
146164 contentStartLine = startLine . number + 1 ;
@@ -149,7 +167,6 @@ function getActiveScope(
149167 const tabSize = getTabSize ( state ) ;
150168 let level = 0 ;
151169
152- // Find the first non-blank line inside the scope to determine indent level
153170 for ( let ln = contentStartLine ; ln <= endLine . number ; ln ++ ) {
154171 const line = state . doc . line ( ln ) ;
155172 if ( ! isBlankLine ( line . text ) ) {
@@ -228,56 +245,31 @@ function getActiveScopeByIndentation(
228245 return { level : cursorLevel , startLine, endLine } ;
229246}
230247
231- /**
232- * Widget that renders indent guide lines
233- */
234- class IndentGuidesWidget extends WidgetType {
235- constructor (
236- readonly levels : number ,
237- readonly indentUnit : number ,
238- readonly activeGuideIndex : number ,
239- readonly lineHeight : number ,
240- ) {
241- super ( ) ;
242- }
243-
244- eq ( other : IndentGuidesWidget ) : boolean {
245- return (
246- other . levels === this . levels &&
247- other . indentUnit === this . indentUnit &&
248- other . activeGuideIndex === this . activeGuideIndex &&
249- other . lineHeight === this . lineHeight
250- ) ;
251- }
252-
253- toDOM ( ) : HTMLElement {
254- const container = document . createElement ( "span" ) ;
255- container . className = "cm-indent-guides-wrapper" ;
256- container . setAttribute ( "aria-hidden" , "true" ) ;
257-
258- const guidesContainer = document . createElement ( "span" ) ;
259- guidesContainer . className = "cm-indent-guides" ;
260-
261- for ( let i = 0 ; i < this . levels ; i ++ ) {
262- const guide = document . createElement ( "span" ) ;
263- guide . className = "cm-indent-guide" ;
264- guide . style . left = `${ i * this . indentUnit } ch` ;
265- guide . style . height = `${ this . lineHeight } px` ;
266-
267- if ( i === this . activeGuideIndex ) {
268- guide . classList . add ( "cm-indent-guide-active" ) ;
269- }
270-
271- guidesContainer . appendChild ( guide ) ;
272- }
273-
274- container . appendChild ( guidesContainer ) ;
275- return container ;
248+ function buildGuideStyle (
249+ levels : number ,
250+ guideStepPx : number ,
251+ activeGuideIndex : number ,
252+ ) : string {
253+ const images = [ ] ;
254+ const positions = [ ] ;
255+ const sizes = [ ] ;
256+
257+ for ( let i = 0 ; i < levels ; i ++ ) {
258+ const color =
259+ i === activeGuideIndex
260+ ? "var(--indent-guide-active-color)"
261+ : "var(--indent-guide-color)" ;
262+ images . push ( `linear-gradient(${ color } , ${ color } )` ) ;
263+ positions . push ( `${ i * guideStepPx } px 0` ) ;
264+ sizes . push ( "1px 100%" ) ;
276265 }
277266
278- ignoreEvent ( ) : boolean {
279- return true ;
280- }
267+ return [
268+ `background-image:${ images . join ( "," ) } ` ,
269+ "background-repeat:no-repeat" ,
270+ `background-position:${ positions . join ( "," ) } ` ,
271+ `background-size:${ sizes . join ( "," ) } ` ,
272+ ] . join ( ";" ) ;
281273}
282274
283275/**
@@ -290,16 +282,13 @@ function buildDecorations(
290282 const builder = new RangeSetBuilder < Decoration > ( ) ;
291283 const { state } = view ;
292284 const tabSize = getTabSize ( state ) ;
293- const indentUnit = tabSize ;
285+ const indentUnit = getIndentUnitColumns ( state ) ;
286+ const guideStepPx = Math . max ( view . defaultCharacterWidth * indentUnit , 1 ) ;
294287
295- // Get active scope using syntax tree (or fallback to indentation)
296288 const activeScope = config . highlightActiveGuide
297289 ? getActiveScope ( view , indentUnit )
298290 : null ;
299291
300- const lineHeight = view . defaultLineHeight ;
301-
302- // Only process visible lines for performance
303292 for ( const { from : blockFrom , to : blockTo } of view . visibleRanges ) {
304293 const startLine = state . doc . lineAt ( blockFrom ) ;
305294 const endLine = state . doc . lineAt ( blockTo ) ;
@@ -314,34 +303,30 @@ function buildDecorations(
314303
315304 const indentColumns = getLineIndentation ( lineText , tabSize ) ;
316305 const levels = Math . floor ( indentColumns / indentUnit ) ;
317-
318- if ( levels > 0 ) {
319- let activeGuideIndex = - 1 ;
320-
321- // Check if this line is in the active scope
322- if (
323- activeScope &&
324- lineNum >= activeScope . startLine &&
325- lineNum <= activeScope . endLine &&
326- levels >= activeScope . level
327- ) {
328- activeGuideIndex = activeScope . level - 1 ;
329- }
330-
331- const widget = new IndentGuidesWidget (
332- levels ,
333- indentUnit ,
334- activeGuideIndex ,
335- lineHeight ,
336- ) ;
337-
338- const deco = Decoration . widget ( {
339- widget,
340- side : - 1 ,
341- } ) ;
342-
343- builder . add ( line . from , line . from , deco ) ;
306+ if ( levels <= 0 ) continue ;
307+ const leadingWhitespaceLength = getLeadingWhitespaceLength ( lineText ) ;
308+ if ( leadingWhitespaceLength <= 0 ) continue ;
309+
310+ let activeGuideIndex = - 1 ;
311+ if (
312+ activeScope &&
313+ lineNum >= activeScope . startLine &&
314+ lineNum <= activeScope . endLine &&
315+ levels >= activeScope . level
316+ ) {
317+ activeGuideIndex = activeScope . level - 1 ;
344318 }
319+
320+ builder . add (
321+ line . from ,
322+ line . from + leadingWhitespaceLength ,
323+ Decoration . mark ( {
324+ attributes : {
325+ class : GUIDE_MARK_CLASS ,
326+ style : buildGuideStyle ( levels , guideStepPx , activeGuideIndex ) ,
327+ } ,
328+ } ) ,
329+ ) ;
345330 }
346331 }
347332
@@ -366,7 +351,6 @@ function createIndentGuidesPlugin(
366351 }
367352
368353 update ( update : ViewUpdate ) : void {
369- // Only rebuild when necessary
370354 if (
371355 update . docChanged ||
372356 update . viewportChanged ||
@@ -384,34 +368,13 @@ function createIndentGuidesPlugin(
384368}
385369
386370/**
387- * Theme for indent guides with subtle animation
371+ * Theme for indent guides.
372+ * Uses a single span around leading indentation instead of per-guide widgets.
388373 */
389374const indentGuidesTheme = EditorView . baseTheme ( {
390- ".cm-indent-guides-wrapper" : {
391- display : "inline" ,
392- position : "relative" ,
393- width : "0" ,
394- height : "0" ,
395- overflow : "visible" ,
396- verticalAlign : "top" ,
397- } ,
398375 ".cm-indent-guides" : {
399- position : "absolute" ,
400- top : "0" ,
401- left : "0" ,
402- height : "100%" ,
403- pointerEvents : "none" ,
404- zIndex : "0" ,
405- } ,
406- ".cm-indent-guide" : {
407- position : "absolute" ,
408- top : "0" ,
409- width : "1px" ,
410- background : "var(--indent-guide-color)" ,
411- transition : "background 0.15s ease, opacity 0.15s ease" ,
412- } ,
413- ".cm-indent-guide-active" : {
414- background : "var(--indent-guide-active-color)" ,
376+ display : "inline-block" ,
377+ verticalAlign : "top" ,
415378 } ,
416379 "&" : {
417380 "--indent-guide-color" : "rgba(128, 128, 128, 0.25)" ,
0 commit comments