Skip to content

Commit b06671d

Browse files
committed
Merge branch 'mgmeyers-heading-wc'
2 parents a85a502 + c0e2c13 commit b06671d

File tree

7 files changed

+375
-57
lines changed

7 files changed

+375
-57
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"license": "MIT",
2121
"devDependencies": {
2222
"@codemirror/commands": "^6.1.2",
23-
"@codemirror/language": "^6.3.0",
23+
"@codemirror/language": "https://github.com/lishid/cm-language",
2424
"@codemirror/search": "^6.2.2",
2525
"@codemirror/state": "^6.1.2",
2626
"@codemirror/text": "^0.19.6",

src/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@ export const MATCH_HTML_COMMENT = new RegExp(
99
"|<[?][^>]*>?",
1010
"g"
1111
);
12-
export const MATCH_COMMENT = new RegExp("%%[^%%]+%%", "g");
12+
export const MATCH_COMMENT = new RegExp("%%[\\s\\S]*?(?!%%)[\\s\\S]+?%%", "g");
1313
export const MATCH_PARAGRAPH = new RegExp("\n([^\n]+)\n", "g");

src/editor/EditorPlugin.ts

Lines changed: 315 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,47 @@
1-
import { Transaction } from "@codemirror/state";
1+
import { EditorState, Line, RangeSetBuilder, StateEffect, StateField, Transaction } from "@codemirror/state";
22
import {
33
ViewUpdate,
44
PluginValue,
55
EditorView,
66
ViewPlugin,
7+
DecorationSet,
8+
Decoration,
9+
WidgetType,
710
} from "@codemirror/view";
11+
import { syntaxTree } from "@codemirror/language";
812
import 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 && /comment/.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

Comments
 (0)