1- import { RangeSetBuilder , StateEffect , StateField , Transaction } from "@codemirror/state" ;
1+ import { Line , RangeSetBuilder , StateEffect , StateField , Text , Transaction } from "@codemirror/state" ;
22import {
33 ViewUpdate ,
44 PluginValue ,
88 Decoration ,
99 WidgetType ,
1010} from "@codemirror/view" ;
11- import { App , HeadingCache , TFile , editorInfoField } from "obsidian" ;
1211import type BetterWordCount from "src/main" ;
1312import { getWordCount } from "src/utils/StatUtils" ;
1413
@@ -78,161 +77,228 @@ class StatusBarEditorPlugin implements PluginValue {
7877
7978export const statusBarEditorPlugin = ViewPlugin . fromClass ( StatusBarEditorPlugin ) ;
8079
81- interface HeadingRange {
82- heading : HeadingCache ;
83- from : number ;
84- to : number ;
80+ interface SectionCountData {
81+ line : number ;
82+ level : number ;
83+ self : number ;
84+ total : number ;
85+ pos : number ;
8586}
8687
87- function getHeadingRanges ( app : App , file : TFile , end : number ) {
88- const fileCache = app . metadataCache . getFileCache ( file ) ;
89-
90- if ( ! fileCache ?. headings ?. length ) return null ;
91-
92- const nestedHeadings : HeadingCache [ ] = [ ] ;
93- const ranges : HeadingRange [ ] = [ ] ;
94-
95- for ( let i = 0 , len = fileCache . headings . length ; i < len ; i ++ ) {
96- const heading = fileCache . headings [ i ] ;
97- const lastHeading = nestedHeadings . last ( ) ;
98- const isLast = i === len - 1 ;
99-
100- if ( ! lastHeading || heading . level > lastHeading . level ) {
101- // First heading, or traversing to higher level heading (eg ## -> ###)
102- nestedHeadings . push ( heading ) ;
103- } else if ( heading . level === lastHeading . level ) {
104- // Two headings of the same level
105- const nestedHeading = nestedHeadings . pop ( ) ;
106- ranges . push ( {
107- heading : nestedHeading ,
108- from : nestedHeading . position . end . offset ,
109- to : heading . position . start . offset ,
110- } ) ;
111- nestedHeadings . push ( heading ) ;
112- } else if ( heading . level < lastHeading . level ) {
113- // Traversing to lower level heading (eg. ### -> ##)
114- for ( let j = nestedHeadings . length - 1 ; j >= 0 ; j -- ) {
115- const nestedHeading = nestedHeadings [ j ] ;
116-
117- if ( heading . level < nestedHeading . level ) {
118- // Continue traversing to lower level heading
119- const nestedHeading = nestedHeadings . pop ( ) ;
120- ranges . push ( {
121- heading : nestedHeading ,
122- from : nestedHeading . position . end . offset ,
123- to : heading . position . start . offset ,
124- } ) ;
125- if ( j === 0 ) {
126- nestedHeadings . push ( heading ) ;
127- }
128- continue ;
129- }
88+ class SectionWidget extends WidgetType {
89+ plugin : BetterWordCount ;
90+ data : SectionCountData ;
13091
131- if ( heading . level === nestedHeading . level ) {
132- // Stop because we found an equal level heading
133- const nestedHeading = nestedHeadings . pop ( ) ;
134- ranges . push ( {
135- heading : nestedHeading ,
136- from : nestedHeading . position . end . offset ,
137- to : heading . position . start . offset ,
138- } ) ;
139- nestedHeadings . push ( heading ) ;
140- break ;
141- }
92+ constructor ( plugin : BetterWordCount , data : SectionCountData ) {
93+ super ( ) ;
94+ this . plugin = plugin ;
95+ this . data = data ;
96+ }
14297
143- if ( heading . level > nestedHeading . level ) {
144- // Stop because we found an higher level heading
145- nestedHeadings . push ( heading ) ;
146- break ;
147- }
148- }
149- } else if ( isLast ) {
150- // Final heading
151- nestedHeadings . push ( heading ) ;
152- }
98+ eq ( widget : this) : boolean {
99+ const { pos, self, total } = this . data ;
100+ return pos === widget . data . pos && self === widget . data . self && total === widget . data . total ;
101+ }
153102
154- if ( isLast ) {
155- // Flush the remaining headings
156- let nestedHeading : HeadingCache ;
157- while ( ( nestedHeading = nestedHeadings . pop ( ) ) ) {
158- ranges . push ( {
159- heading : nestedHeading ,
160- from : nestedHeading . position . end . offset ,
161- to : end ,
162- } ) ;
163- }
103+ getDisplayText ( ) {
104+ const { self, total } = this . data ;
105+ if ( self && self !== total ) {
106+ return `${ self } / ${ total } ` ;
164107 }
108+ return total . toString ( ) ;
165109 }
166110
167- // Sort the headings in the order they appear in the document
168- ranges . sort ( ( a , b ) => a . from - b . from ) ;
169-
170- return ranges ;
111+ toDOM ( ) {
112+ return createSpan ( { cls : "bwc-section-count" , text : this . getDisplayText ( ) } ) ;
113+ }
171114}
172115
173116class SectionWordCountEditorPlugin implements PluginValue {
174117 decorations : DecorationSet ;
118+ lineCounts : any [ ] = [ ] ;
175119
176120 constructor ( view : EditorView ) {
121+ const plugin = view . state . field ( pluginField ) ;
122+ if ( ! plugin . settings . displaySectionCounts ) {
123+ this . decorations = Decoration . none ;
124+ return ;
125+ }
126+
127+ this . calculateLineCounts ( view . state . doc ) ;
177128 this . decorations = this . mkDeco ( view ) ;
178129 }
179130
131+ calculateLineCounts ( doc : Text ) {
132+ for ( let index = 0 ; index < doc . lines ; index ++ ) {
133+ const line = doc . line ( index + 1 ) ;
134+ this . lineCounts . push ( getWordCount ( line . text ) ) ;
135+ }
136+ }
137+
180138 update ( update : ViewUpdate ) {
181139 const plugin = update . view . state . field ( pluginField ) ;
182- if ( ! plugin . settings . displaySectionCounts ) {
183- if ( this . decorations . size ) {
184- // Clear out any decorations
185- this . decorations = this . mkDeco ( update . view ) ;
186- }
140+ const { displaySectionCounts } = plugin . settings ;
141+ let didSettingsChange = false ;
187142
143+ if ( this . lineCounts . length && ! displaySectionCounts ) {
144+ this . lineCounts = [ ] ;
145+ this . decorations = Decoration . none ;
188146 return ;
147+ } else if ( ! this . lineCounts . length && displaySectionCounts ) {
148+ didSettingsChange = true ;
149+ this . calculateLineCounts ( update . startState . doc ) ;
189150 }
190151
191- update . transactions . forEach ( ( tr ) => {
192- if ( tr . effects . some ( ( e ) => e . is ( metadataUpdated ) ) ) {
193- // If metadata has been updated, rebuild the decorations
194- this . decorations = this . mkDeco ( update . view ) ;
195- } else if ( update . docChanged ) {
196- // Otherwise just update their positions
197- this . decorations = this . decorations . map ( tr . changes ) ;
198- }
199- } ) ;
152+ if ( update . docChanged ) {
153+ const startDoc = update . startState . doc ;
154+ let tempDoc = startDoc ;
155+
156+ update . changes . iterChanges ( ( fromA , toA , fromB , toB , text ) => {
157+ const from = fromB ;
158+ const to = fromB + ( toA - fromA ) ;
159+ const nextTo = from + text . length ;
160+
161+ const fromLine = tempDoc . lineAt ( from ) ;
162+ const toLine = tempDoc . lineAt ( to ) ;
163+
164+ tempDoc = tempDoc . replace ( fromB , fromB + ( toA - fromA ) , text ) ;
165+
166+ const fromLineNext = tempDoc . lineAt ( from ) ;
167+ const toLineNext = tempDoc . lineAt ( nextTo ) ;
168+
169+ const lines : any [ ] = [ ] ;
170+
171+ for ( let i = fromLineNext . number ; i <= toLineNext . number ; i ++ ) {
172+ lines . push ( getWordCount ( tempDoc . line ( i ) . text ) ) ;
173+ }
174+
175+ const spliceStart = fromLine . number - 1 ;
176+ const spliceLen = toLine . number - fromLine . number + 1 ;
177+
178+ this . lineCounts . splice ( spliceStart , spliceLen , ...lines ) ;
179+ } ) ;
180+ }
181+
182+ if ( update . docChanged || update . viewportChanged || didSettingsChange ) {
183+ this . decorations = this . mkDeco ( update . view ) ;
184+ }
200185 }
201186
202187 mkDeco ( view : EditorView ) {
203188 const plugin = view . state . field ( pluginField ) ;
204189 const b = new RangeSetBuilder < Decoration > ( ) ;
205190 if ( ! plugin . settings . displaySectionCounts ) return b . finish ( ) ;
206191
207- const { app, file } = view . state . field ( editorInfoField ) ;
208- if ( ! file ) return b . finish ( ) ;
209-
210- const headingRanges = getHeadingRanges ( app , file , view . state . doc . length - 1 ) ;
211- if ( ! headingRanges ?. length ) return b . finish ( ) ;
212-
213- for ( let i = 0 ; i < headingRanges . length ; i ++ ) {
214- const heading = headingRanges [ i ] ;
215- const next = headingRanges [ i + 1 ] ;
216- const targetPos = heading . heading . position . start . offset ;
192+ const getHeaderLevel = ( line : Line ) => {
193+ const match = line . text . match ( / ^ ( # + ) [ \t ] / ) ;
194+ return match ? match [ 1 ] . length : null ;
195+ } ;
196+
197+ const doc = view . state . doc ;
198+ const lineCount = doc . lines ;
199+ const sectionCounts : SectionCountData [ ] = [ ] ;
200+ const nested : SectionCountData [ ] = [ ] ;
201+
202+ for ( const { from } of view . visibleRanges ) {
203+ const lineStart = doc . lineAt ( from ) ;
204+
205+ for ( let i = lineStart . number , len = lineCount ; i <= len ; i ++ ) {
206+ let line : Line ;
207+ if ( i === lineStart . number ) line = lineStart ;
208+ else line = doc . line ( i ) ;
209+
210+ const level = getHeaderLevel ( line ) ;
211+ const prevHeading = nested . last ( ) ;
212+ if ( level ) {
213+ if ( ! prevHeading || level > prevHeading . level ) {
214+ nested . push ( {
215+ line : i ,
216+ level,
217+ self : 0 ,
218+ total : 0 ,
219+ pos : line . to ,
220+ } ) ;
221+ } else if ( prevHeading . level === level ) {
222+ const nestedHeading = nested . pop ( ) ;
223+ sectionCounts . push ( nestedHeading ) ;
224+ nested . push ( {
225+ line : i ,
226+ level,
227+ self : 0 ,
228+ total : 0 ,
229+ pos : line . to ,
230+ } ) ;
231+ } else if ( prevHeading . level > level ) {
232+ // Traversing to lower level heading (eg. ### -> ##)
233+ for ( let j = nested . length - 1 ; j >= 0 ; j -- ) {
234+ const nestedHeading = nested [ j ] ;
235+
236+ if ( level < nestedHeading . level ) {
237+ // Continue traversing to lower level heading
238+ const nestedHeading = nested . pop ( ) ;
239+ sectionCounts . push ( nestedHeading ) ;
240+ if ( j === 0 ) {
241+ nested . push ( {
242+ line : i ,
243+ level,
244+ self : 0 ,
245+ total : 0 ,
246+ pos : line . to ,
247+ } ) ;
248+ }
249+ continue ;
250+ }
251+
252+ if ( level === nestedHeading . level ) {
253+ // Stop because we found an equal level heading
254+ const nestedHeading = nested . pop ( ) ;
255+ sectionCounts . push ( nestedHeading ) ;
256+ nested . push ( {
257+ line : i ,
258+ level,
259+ self : 0 ,
260+ total : 0 ,
261+ pos : line . to ,
262+ } ) ;
263+ break ;
264+ }
265+
266+ if ( level > nestedHeading . level ) {
267+ // Stop because we found an higher level heading
268+ nested . push ( {
269+ line : i ,
270+ level,
271+ self : 0 ,
272+ total : 0 ,
273+ pos : line . to ,
274+ } ) ;
275+ break ;
276+ }
277+ }
278+ }
279+ } else if ( nested . length ) {
280+ const count = this . lineCounts [ i - 1 ] ;
281+ for ( const heading of nested ) {
282+ if ( heading === prevHeading ) {
283+ heading . self += count ;
284+ }
285+ heading . total += count ;
286+ }
287+ }
288+ }
289+ }
217290
218- const totalCount = getWordCount ( view . state . doc . slice ( heading . from , heading . to ) . toString ( ) ) ;
219- let selfCount : number ;
291+ if ( nested . length ) sectionCounts . push ( ...nested ) ;
220292
221- if ( next && next . heading . level > heading . heading . level ) {
222- const betweenCount = getWordCount (
223- view . state . doc . slice ( heading . from , next . heading . position . start . offset ) . toString ( )
224- ) ;
225- if ( betweenCount ) selfCount = betweenCount ;
226- }
293+ sectionCounts . sort ( ( a , b ) => a . line - b . line ) ;
227294
295+ for ( const data of sectionCounts ) {
228296 b . add (
229- targetPos ,
230- targetPos ,
231- Decoration . line ( {
232- attributes : {
233- class : "bwc-section-count" ,
234- style : `--word-count: "${ selfCount ? `${ selfCount } / ${ totalCount } ` : totalCount . toString ( ) } "` ,
235- } ,
297+ data . pos ,
298+ data . pos ,
299+ Decoration . widget ( {
300+ side : 1 ,
301+ widget : new SectionWidget ( plugin , data ) ,
236302 } )
237303 ) ;
238304 }
@@ -241,7 +307,7 @@ class SectionWordCountEditorPlugin implements PluginValue {
241307 }
242308}
243309
244- export const metadataUpdated = StateEffect . define < void > ( ) ;
310+ export const settingsChanged = StateEffect . define < void > ( ) ;
245311export const sectionWordCountEditorPlugin = ViewPlugin . fromClass ( SectionWordCountEditorPlugin , {
246312 decorations : ( v ) => v . decorations ,
247313} ) ;
0 commit comments