1- import { Transaction } from "@codemirror/state" ;
1+ import { EditorState , Line , RangeSetBuilder , StateEffect , StateField , Transaction } from "@codemirror/state" ;
22import {
33 ViewUpdate ,
44 PluginValue ,
55 EditorView ,
66 ViewPlugin ,
7+ DecorationSet ,
8+ Decoration ,
9+ WidgetType ,
710} from "@codemirror/view" ;
11+ import { syntaxTree } from "@codemirror/language" ;
812import type BetterWordCount from "src/main" ;
13+ import { getWordCount } from "src/utils/StatUtils" ;
14+ import { MATCH_COMMENT , MATCH_HTML_COMMENT } from "src/constants" ;
915
10- class EditorPlugin implements PluginValue {
11- hasPlugin : boolean ;
16+ export const pluginField = StateField . define < BetterWordCount > ( {
17+ create ( ) {
18+ return null ;
19+ } ,
20+ update ( state ) {
21+ return state ;
22+ } ,
23+ } ) ;
24+
25+ class StatusBarEditorPlugin implements PluginValue {
1226 view : EditorView ;
13- private plugin : BetterWordCount ;
1427
1528 constructor ( view : EditorView ) {
1629 this . view = view ;
17- this . hasPlugin = false ;
1830 }
1931
2032 update ( update : ViewUpdate ) : void {
21- if ( ! this . hasPlugin ) {
22- return ;
23- }
24-
2533 const tr = update . transactions [ 0 ] ;
2634
2735 if ( ! tr ) {
2836 return ;
2937 }
3038
39+ const plugin = update . view . state . field ( pluginField ) ;
40+
3141 // When selecting text with Shift+Home the userEventType is undefined.
3242 // This is probably a bug in codemirror, for the time being doing an explict check
3343 // for the type allows us to update the stats for the selection.
34- const userEventTypeUndefined =
35- tr . annotation ( Transaction . userEvent ) === undefined ;
44+ const userEventTypeUndefined = tr . annotation ( Transaction . userEvent ) === undefined ;
3645
3746 if (
3847 ( tr . isUserEvent ( "select" ) || userEventTypeUndefined ) &&
@@ -44,7 +53,7 @@ class EditorPlugin implements PluginValue {
4453 while ( ! textIter . done ) {
4554 text = text + textIter . next ( ) . value ;
4655 }
47- this . plugin . statusBar . debounceStatusBarUpdate ( text ) ;
56+ plugin . statusBar . debounceStatusBarUpdate ( text ) ;
4857 } else if (
4958 tr . isUserEvent ( "input" ) ||
5059 tr . isUserEvent ( "delete" ) ||
@@ -58,19 +67,305 @@ class EditorPlugin implements PluginValue {
5867 while ( ! textIter . done ) {
5968 text = text + textIter . next ( ) . value ;
6069 }
61- if ( tr . docChanged && this . plugin . statsManager ) {
62- this . plugin . statsManager . debounceChange ( text ) ;
70+ if ( tr . docChanged && plugin . statsManager ) {
71+ plugin . statsManager . debounceChange ( text ) ;
6372 }
64- this . plugin . statusBar . debounceStatusBarUpdate ( text ) ;
73+ plugin . statusBar . debounceStatusBarUpdate ( text ) ;
6574 }
6675 }
6776
68- addPlugin ( plugin : BetterWordCount ) {
69- this . plugin = plugin ;
70- this . hasPlugin = true ;
77+ destroy ( ) { }
78+ }
79+
80+ export const statusBarEditorPlugin = ViewPlugin . fromClass ( StatusBarEditorPlugin ) ;
81+
82+ interface SectionCountData {
83+ line : number ;
84+ level : number ;
85+ self : number ;
86+ total : number ;
87+ pos : number ;
88+ }
89+
90+ class SectionWidget extends WidgetType {
91+ data : SectionCountData ;
92+
93+ constructor ( data : SectionCountData ) {
94+ super ( ) ;
95+ this . data = data ;
96+ }
97+
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 ;
71101 }
72102
73- destroy ( ) { }
103+ getDisplayText ( ) {
104+ const { self, total } = this . data ;
105+ if ( self && self !== total ) {
106+ return `${ self } / ${ total } ` ;
107+ }
108+ return total . toString ( ) ;
109+ }
110+
111+ toDOM ( ) {
112+ return createSpan ( { cls : "bwc-section-count" , text : this . getDisplayText ( ) } ) ;
113+ }
114+ }
115+
116+ const mdCommentRe = / % % / g;
117+ class SectionWordCountEditorPlugin implements PluginValue {
118+ decorations : DecorationSet ;
119+ lineCounts : any [ ] = [ ] ;
120+
121+ constructor ( view : EditorView ) {
122+ const plugin = view . state . field ( pluginField ) ;
123+ if ( ! plugin . settings . displaySectionCounts ) {
124+ this . decorations = Decoration . none ;
125+ return ;
126+ }
127+
128+ this . calculateLineCounts ( view . state , plugin ) ;
129+ this . decorations = this . mkDeco ( view ) ;
130+ }
131+
132+ calculateLineCounts ( state : EditorState , plugin : BetterWordCount ) {
133+ const stripComments = plugin . settings . countComments ;
134+ let docStr = state . doc . toString ( ) ;
135+
136+ if ( stripComments ) {
137+ // Strip out comments, but preserve new lines for accurate positioning data
138+ const preserveNl = ( match : string , offset : number , str : string ) => {
139+ let output = '' ;
140+ for ( let i = offset , len = offset + match . length ; i < len ; i ++ ) {
141+ if ( / [ \r \n ] / . test ( str [ i ] ) ) {
142+ output += str [ i ] ;
143+ }
144+ }
145+ return output ;
146+ }
147+
148+ docStr = docStr . replace ( MATCH_COMMENT , preserveNl ) . replace ( MATCH_HTML_COMMENT , preserveNl ) ;
149+ }
150+
151+ const lines = docStr . split ( state . facet ( EditorState . lineSeparator ) || / \r \n ? | \n / )
152+
153+ for ( let i = 0 , len = lines . length ; i < len ; i ++ ) {
154+ let line = lines [ i ] ;
155+ this . lineCounts . push ( getWordCount ( line ) ) ;
156+ }
157+ }
158+
159+ update ( update : ViewUpdate ) {
160+ const plugin = update . view . state . field ( pluginField ) ;
161+ const { displaySectionCounts, countComments : stripComments } = plugin . settings ;
162+ let didSettingsChange = false ;
163+
164+ if ( this . lineCounts . length && ! displaySectionCounts ) {
165+ this . lineCounts = [ ] ;
166+ this . decorations = Decoration . none ;
167+ return ;
168+ } else if ( ! this . lineCounts . length && displaySectionCounts ) {
169+ didSettingsChange = true ;
170+ this . calculateLineCounts ( update . startState , plugin ) ;
171+ }
172+
173+ if ( update . docChanged ) {
174+ const startDoc = update . startState . doc ;
175+
176+ let tempDoc = startDoc ;
177+ let editStartLine = Infinity ;
178+ let editEndLine = - Infinity ;
179+
180+ update . changes . iterChanges ( ( fromA , toA , fromB , toB , text ) => {
181+ const from = fromB ;
182+ const to = fromB + ( toA - fromA ) ;
183+ const nextTo = from + text . length ;
184+
185+ const fromLine = tempDoc . lineAt ( from ) ;
186+ const toLine = tempDoc . lineAt ( to ) ;
187+
188+ tempDoc = tempDoc . replace ( fromB , fromB + ( toA - fromA ) , text ) ;
189+
190+ const nextFromLine = tempDoc . lineAt ( from ) ;
191+ const nextToLine = tempDoc . lineAt ( nextTo ) ;
192+ const lines : any [ ] = [ ] ;
193+
194+ for ( let i = nextFromLine . number ; i <= nextToLine . number ; i ++ ) {
195+ lines . push ( getWordCount ( tempDoc . line ( i ) . text ) ) ;
196+ }
197+
198+ const spliceStart = fromLine . number - 1 ;
199+ const spliceLen = toLine . number - fromLine . number + 1 ;
200+
201+ editStartLine = Math . min ( editStartLine , spliceStart ) ;
202+ editEndLine = Math . max ( editEndLine , spliceStart + ( nextToLine . number - nextFromLine . number + 1 ) ) ;
203+
204+ this . lineCounts . splice ( spliceStart , spliceLen , ...lines ) ;
205+ } ) ;
206+
207+ // Filter out any counts associated with comments in the lines that were edited
208+ if ( stripComments ) {
209+ const tree = syntaxTree ( update . state ) ;
210+ for ( let i = editStartLine ; i < editEndLine ; i ++ ) {
211+ const line = update . state . doc . line ( i + 1 ) ;
212+ let newLine = '' ;
213+ let pos = 0 ;
214+ let foundComment = false ;
215+
216+ tree . iterate ( {
217+ enter ( node ) {
218+ if ( node . name && / c o m m e n t / . test ( node . name ) ) {
219+ foundComment = true ;
220+ newLine += line . text . substring ( pos , node . from - line . from ) ;
221+ pos = node . to - line . from ;
222+ }
223+ } ,
224+ from : line . from ,
225+ to : line . to ,
226+ } ) ;
227+
228+ if ( foundComment ) {
229+ newLine += line . text . substring ( pos ) ;
230+ this . lineCounts [ i ] = getWordCount ( newLine ) ;
231+ }
232+ }
233+ }
234+ }
235+
236+ if ( update . docChanged || update . viewportChanged || didSettingsChange ) {
237+ this . decorations = this . mkDeco ( update . view ) ;
238+ }
239+ }
240+
241+ mkDeco ( view : EditorView ) {
242+ const plugin = view . state . field ( pluginField ) ;
243+ const b = new RangeSetBuilder < Decoration > ( ) ;
244+ if ( ! plugin . settings . displaySectionCounts ) return b . finish ( ) ;
245+
246+ const getHeaderLevel = ( line : Line ) => {
247+ const match = line . text . match ( / ^ ( # + ) [ \t ] / ) ;
248+ return match ? match [ 1 ] . length : null ;
249+ } ;
250+
251+ if ( ! view . visibleRanges . length ) return b . finish ( ) ;
252+
253+ // Start processing from the beginning of the first visible range
254+ const { from } = view . visibleRanges [ 0 ] ;
255+ const doc = view . state . doc ;
256+ const lineStart = doc . lineAt ( from ) ;
257+ const lineCount = doc . lines ;
258+ const sectionCounts : SectionCountData [ ] = [ ] ;
259+ const nested : SectionCountData [ ] = [ ] ;
260+
261+ for ( let i = lineStart . number ; i <= lineCount ; i ++ ) {
262+ let line : Line ;
263+ if ( i === lineStart . number ) line = lineStart ;
264+ else line = doc . line ( i ) ;
265+
266+ const level = getHeaderLevel ( line ) ;
267+ const prevHeading = nested . last ( ) ;
268+ if ( level ) {
269+ if ( ! prevHeading || level > prevHeading . level ) {
270+ // The first heading or moving to a higher level eg. ## -> ###
271+ nested . push ( {
272+ line : i ,
273+ level,
274+ self : 0 ,
275+ total : 0 ,
276+ pos : line . to ,
277+ } ) ;
278+ } else if ( prevHeading . level === level ) {
279+ // Same level as the previous heading
280+ const nestedHeading = nested . pop ( ) ;
281+ sectionCounts . push ( nestedHeading ) ;
282+ nested . push ( {
283+ line : i ,
284+ level,
285+ self : 0 ,
286+ total : 0 ,
287+ pos : line . to ,
288+ } ) ;
289+ } else if ( prevHeading . level > level ) {
290+ // Traversing to lower level heading (eg. ### -> ##)
291+ for ( let j = nested . length - 1 ; j >= 0 ; j -- ) {
292+ const nestedHeading = nested [ j ] ;
293+
294+ if ( level < nestedHeading . level ) {
295+ // Continue traversing to lower level heading
296+ const nestedHeading = nested . pop ( ) ;
297+ sectionCounts . push ( nestedHeading ) ;
298+ if ( j === 0 ) {
299+ nested . push ( {
300+ line : i ,
301+ level,
302+ self : 0 ,
303+ total : 0 ,
304+ pos : line . to ,
305+ } ) ;
306+ }
307+ continue ;
308+ }
309+
310+ if ( level === nestedHeading . level ) {
311+ // Stop because we found an equal level heading
312+ const nestedHeading = nested . pop ( ) ;
313+ sectionCounts . push ( nestedHeading ) ;
314+ nested . push ( {
315+ line : i ,
316+ level,
317+ self : 0 ,
318+ total : 0 ,
319+ pos : line . to ,
320+ } ) ;
321+ break ;
322+ }
323+
324+ if ( level > nestedHeading . level ) {
325+ // Stop because we found an higher level heading
326+ nested . push ( {
327+ line : i ,
328+ level,
329+ self : 0 ,
330+ total : 0 ,
331+ pos : line . to ,
332+ } ) ;
333+ break ;
334+ }
335+ }
336+ }
337+ } else if ( nested . length ) {
338+ // Not in a heading, so add the word count of the line to the headings containing this line
339+ const count = this . lineCounts [ i - 1 ] ;
340+ for ( const heading of nested ) {
341+ if ( heading === prevHeading ) {
342+ heading . self += count ;
343+ }
344+ heading . total += count ;
345+ }
346+ }
347+ }
348+
349+ if ( nested . length ) sectionCounts . push ( ...nested ) ;
350+
351+ sectionCounts . sort ( ( a , b ) => a . line - b . line ) ;
352+
353+ for ( const data of sectionCounts ) {
354+ b . add (
355+ data . pos ,
356+ data . pos ,
357+ Decoration . widget ( {
358+ side : 1 ,
359+ widget : new SectionWidget ( data ) ,
360+ } )
361+ ) ;
362+ }
363+
364+ return b . finish ( ) ;
365+ }
74366}
75367
76- export const editorPlugin = ViewPlugin . fromClass ( EditorPlugin ) ;
368+ export const settingsChanged = StateEffect . define < void > ( ) ;
369+ export const sectionWordCountEditorPlugin = ViewPlugin . fromClass ( SectionWordCountEditorPlugin , {
370+ decorations : ( v ) => v . decorations ,
371+ } ) ;
0 commit comments