@batchfy/codemirror-minimap 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/text.ts ADDED
@@ -0,0 +1,465 @@
1
+ import { LineBasedState } from "./linebasedstate.js";
2
+ import { Highlighter, highlightTree } from "@lezer/highlight";
3
+ import { ChangedRange, Tree, TreeFragment } from "@lezer/common";
4
+ import { highlightingFor, language } from "@codemirror/language";
5
+ import { EditorView, ViewUpdate } from "@codemirror/view";
6
+ import { DrawContext } from "./types.js";
7
+ import { Config, Options, Scale } from "./Config.js";
8
+ import { LinesState, foldsChanged } from "./LinesState.js";
9
+ import crelt from "crelt";
10
+ import { ChangeSet, EditorState } from "@codemirror/state";
11
+
12
+ const NON_WHITESPACE = /\S+/g;
13
+
14
+ type TagSpan = { text: string; tags: string };
15
+ type FontInfo = { color: string; font: string; lineHeight: number };
16
+
17
+ export class TextState extends LineBasedState<Array<TagSpan>> {
18
+ private _previousTree: Tree | undefined;
19
+ private _displayText: Required<Options>["displayText"] | undefined;
20
+ private _fontInfoMap: Map<string, FontInfo> = new Map();
21
+ private _themeClasses: Set<string> | undefined;
22
+ private _highlightingCallbackId: number | undefined;
23
+ private _charWidthCache: { font: string; charWidth: number } | undefined;
24
+
25
+ /**
26
+ * Font metrics are derived from `getComputedStyle`, which is only correct
27
+ * once the fonts they describe have actually loaded. A web font that
28
+ * resolves after the first render would otherwise leave every cached
29
+ * measurement stale for the lifetime of the view.
30
+ */
31
+ private readonly onFontsLoaded = () => {
32
+ this._fontInfoMap.clear();
33
+ this._charWidthCache = undefined;
34
+ };
35
+
36
+ public constructor(view: EditorView) {
37
+ super(view);
38
+
39
+ this._themeClasses = new Set(view.dom.classList.values());
40
+ document.fonts?.addEventListener("loadingdone", this.onFontsLoaded);
41
+
42
+ if (view.state.facet(Config).enabled) {
43
+ this.updateImpl(view.state);
44
+ }
45
+ }
46
+
47
+ public destroy() {
48
+ document.fonts?.removeEventListener("loadingdone", this.onFontsLoaded);
49
+ }
50
+
51
+ private shouldUpdate(update: ViewUpdate, themeChanged: boolean) {
52
+ // If the doc changed
53
+ if (update.docChanged) {
54
+ return true;
55
+ }
56
+
57
+ // If configuration settings changed
58
+ if (update.state.facet(Config) !== update.startState.facet(Config)) {
59
+ return true;
60
+ }
61
+
62
+ // If the theme changed
63
+ if (themeChanged) {
64
+ return true;
65
+ }
66
+
67
+ // If the folds changed
68
+ if (foldsChanged(update.transactions)) {
69
+ return true;
70
+ }
71
+
72
+ return false;
73
+ }
74
+
75
+ public update(update: ViewUpdate) {
76
+ /**
77
+ * `themeChanged` consumes the pending change: it records the current
78
+ * classes as it compares them, so a second call in the same update
79
+ * always reports "unchanged". Sample it once and pass the result down.
80
+ */
81
+ const themeChanged = this.themeChanged();
82
+
83
+ if (!this.shouldUpdate(update, themeChanged)) {
84
+ return;
85
+ }
86
+
87
+ if (this._highlightingCallbackId) {
88
+ if (typeof window.requestIdleCallback !== "undefined") {
89
+ cancelIdleCallback(this._highlightingCallbackId);
90
+ } else {
91
+ clearTimeout(this._highlightingCallbackId);
92
+ }
93
+ }
94
+
95
+ this.updateImpl(update.state, update.changes, themeChanged);
96
+ }
97
+
98
+ private updateImpl(
99
+ state: EditorState,
100
+ changes?: ChangeSet,
101
+ themeChanged = true,
102
+ ) {
103
+ this.map.clear();
104
+
105
+ /* Store display text setting for rendering */
106
+ this._displayText = state.facet(Config).displayText;
107
+
108
+ /* If class list has changed, clear and recalculate the font info map */
109
+ if (themeChanged) {
110
+ this._fontInfoMap.clear();
111
+ this._charWidthCache = undefined;
112
+ }
113
+
114
+ /* Incrementally parse the tree based on previous tree + changes */
115
+ let treeFragments: ReadonlyArray<TreeFragment> | undefined = undefined;
116
+ if (this._previousTree && changes) {
117
+ const previousFragments = TreeFragment.addTree(this._previousTree);
118
+
119
+ const changedRanges: Array<ChangedRange> = [];
120
+ changes.iterChangedRanges((fromA, toA, fromB, toB) =>
121
+ changedRanges.push({ fromA, toA, fromB, toB }),
122
+ );
123
+
124
+ treeFragments = TreeFragment.applyChanges(
125
+ previousFragments,
126
+ changedRanges,
127
+ );
128
+ }
129
+
130
+ /* Parse the document into a lezer tree */
131
+ const docToString = state.doc.toString();
132
+ const parser = state.facet(language)?.parser;
133
+ const tree = parser
134
+ ? parser.parse(docToString, treeFragments)
135
+ : undefined;
136
+ this._previousTree = tree;
137
+
138
+ /* Highlight the document, and store the text and tags for each line */
139
+ const highlighter: Highlighter = {
140
+ style: (tags) => highlightingFor(state, tags),
141
+ };
142
+
143
+ let highlights: Array<{ from: number; to: number; tags: string }> = [];
144
+
145
+ if (tree) {
146
+ /**
147
+ * The viewport renders a few extra lines above and below the editor view. To approximate
148
+ * the lines visible in the minimap, we multiply the lines in the viewport by the scale multipliers.
149
+ *
150
+ * Based on the current scroll position, the minimap may show a larger portion of lines above or
151
+ * below the lines currently in the editor view. On a long document, when the scroll position is
152
+ * near the top of the document, the minimap will show a small number of lines above the lines
153
+ * in the editor view, and a large number of lines below the lines in the editor view.
154
+ *
155
+ * To approximate this ratio, we can use the viewport scroll percentage
156
+ *
157
+ * ┌─────────────────────┐
158
+ * │ │
159
+ * │ Extra viewport │
160
+ * │ buffer │
161
+ * ├─────────────────────┼───────┐
162
+ * │ │Minimap│
163
+ * │ │Gutter │
164
+ * │ ├───────┤
165
+ * │ Editor View │Scaled │
166
+ * │ │View │
167
+ * │ │Overlay│
168
+ * │ ├───────┤
169
+ * │ │ │
170
+ * │ │ │
171
+ * ├─────────────────────┼───────┘
172
+ * │ │
173
+ * │ Extra viewport │
174
+ * │ buffer │
175
+ * └─────────────────────┘
176
+ *
177
+ **/
178
+
179
+ const vpLineTop = state.doc.lineAt(this.view.viewport.from).number;
180
+ const vpLineBottom = state.doc.lineAt(this.view.viewport.to).number;
181
+ const vpLineCount = vpLineBottom - vpLineTop;
182
+ const vpScroll = vpLineTop / (state.doc.lines - vpLineCount);
183
+
184
+ const { SizeRatio, PixelMultiplier } = Scale;
185
+ const mmLineCount = vpLineCount * SizeRatio * PixelMultiplier;
186
+ const mmLineRatio = vpScroll * mmLineCount;
187
+
188
+ const mmLineTop = Math.max(1, Math.floor(vpLineTop - mmLineRatio));
189
+ const mmLineBottom = Math.min(
190
+ vpLineBottom + Math.floor(mmLineCount - mmLineRatio),
191
+ state.doc.lines,
192
+ );
193
+
194
+ // Highlight the in-view lines synchronously
195
+ highlightTree(
196
+ tree,
197
+ highlighter,
198
+ (from, to, tags) => {
199
+ highlights.push({ from, to, tags });
200
+ },
201
+ state.doc.line(mmLineTop).from,
202
+ state.doc.line(mmLineBottom).to,
203
+ );
204
+ }
205
+
206
+ // Update the map
207
+ this.updateMapImpl(state, highlights, docToString);
208
+
209
+ // Highlight the entire tree in an idle callback
210
+ highlights = [];
211
+ const highlightingCallback = () => {
212
+ if (tree) {
213
+ highlightTree(tree, highlighter, (from, to, tags) => {
214
+ highlights.push({ from, to, tags });
215
+ });
216
+ this.updateMapImpl(state, highlights, docToString);
217
+ this._highlightingCallbackId = undefined;
218
+ }
219
+ };
220
+ this._highlightingCallbackId =
221
+ typeof window.requestIdleCallback !== "undefined"
222
+ ? requestIdleCallback(highlightingCallback)
223
+ : setTimeout(highlightingCallback);
224
+ }
225
+
226
+ private updateMapImpl(
227
+ state: EditorState,
228
+ highlights: Array<{ from: number; to: number; tags: string }>,
229
+ docToString: string,
230
+ ) {
231
+ this.map.clear();
232
+
233
+ const highlightsIterator = highlights.values();
234
+ let highlightPtr = highlightsIterator.next();
235
+
236
+ for (const [index, line] of state.field(LinesState).entries()) {
237
+ const spans: Array<TagSpan> = [];
238
+
239
+ for (const span of line) {
240
+ // Skip if it's a 0-length span
241
+ if (span.from === span.to) {
242
+ continue;
243
+ }
244
+
245
+ // Append a placeholder for a folded span
246
+ if (span.folded) {
247
+ spans.push({ text: "…", tags: "" });
248
+ continue;
249
+ }
250
+
251
+ let position = span.from;
252
+ while (
253
+ !highlightPtr.done &&
254
+ highlightPtr.value.from < span.to
255
+ ) {
256
+ const { from, to, tags } = highlightPtr.value;
257
+
258
+ // Iterate until our highlight is over the current span
259
+ if (to < position) {
260
+ highlightPtr = highlightsIterator.next();
261
+ continue;
262
+ }
263
+
264
+ // Append unstyled text before the highlight begins
265
+ if (from > position) {
266
+ spans.push({
267
+ text: docToString.slice(position, from),
268
+ tags: "",
269
+ });
270
+ }
271
+
272
+ // A highlight may start before and extend beyond the current span
273
+ const start = Math.max(from, span.from);
274
+ const end = Math.min(to, span.to);
275
+
276
+ // Append the highlighted text
277
+ spans.push({ text: docToString.slice(start, end), tags });
278
+ position = end;
279
+
280
+ // If the highlight continues beyond this span, break from this loop
281
+ if (to > end) {
282
+ break;
283
+ }
284
+
285
+ // Otherwise, move to the next highlight
286
+ highlightPtr = highlightsIterator.next();
287
+ }
288
+
289
+ // If there are remaining spans that did not get highlighted, append them unstyled
290
+ if (position !== span.to) {
291
+ spans.push({
292
+ text: docToString.slice(position, span.to),
293
+ tags: "",
294
+ });
295
+ }
296
+ }
297
+
298
+ // Lines are indexed beginning at 1 instead of 0
299
+ const lineNumber = index + 1;
300
+ this.map.set(lineNumber, spans);
301
+ }
302
+ }
303
+
304
+ public measure(context: CanvasRenderingContext2D): {
305
+ charWidth: number;
306
+ lineHeight: number;
307
+ } {
308
+ const { color, font, lineHeight } = this.getFontInfo("");
309
+
310
+ // The canvas resets its state whenever it is resized, so these are
311
+ // reapplied on every measure even when the metrics themselves are cached.
312
+ context.textBaseline = "ideographic";
313
+ context.fillStyle = color;
314
+ context.font = font;
315
+
316
+ if (this._charWidthCache?.font !== font) {
317
+ this._charWidthCache = {
318
+ font,
319
+ charWidth: context.measureText("_").width,
320
+ };
321
+ }
322
+
323
+ return { charWidth: this._charWidthCache.charWidth, lineHeight };
324
+ }
325
+
326
+ public drawLine(ctx: DrawContext, lineNumber: number) {
327
+ const line = this.get(lineNumber);
328
+ if (!line) {
329
+ return;
330
+ }
331
+
332
+ let { context, charWidth, lineHeight, offsetX, offsetY } = ctx;
333
+
334
+ let prevInfo: FontInfo | undefined;
335
+ context.textBaseline = "ideographic";
336
+
337
+ for (const span of line) {
338
+ const info = this.getFontInfo(span.tags);
339
+
340
+ if (!prevInfo || prevInfo.color !== info.color) {
341
+ context.fillStyle = info.color;
342
+ }
343
+
344
+ if (!prevInfo || prevInfo.font !== info.font) {
345
+ context.font = info.font;
346
+ }
347
+
348
+ prevInfo = info;
349
+
350
+ lineHeight = Math.max(lineHeight, info.lineHeight);
351
+
352
+ switch (this._displayText) {
353
+ case "characters": {
354
+ // TODO: `fillText` takes up the majority of profiling time in `render`
355
+ // Try speeding it up with `drawImage`
356
+ // https://stackoverflow.com/questions/8237030/html5-canvas-faster-filltext-vs-drawimage/8237081
357
+
358
+ context.fillText(span.text, offsetX, offsetY + lineHeight);
359
+ offsetX += span.text.length * charWidth;
360
+ break;
361
+ }
362
+
363
+ case "blocks": {
364
+ // Shared regex, so reset the cursor that a previous span
365
+ // may have left behind after breaking out of the loop.
366
+ NON_WHITESPACE.lastIndex = 0;
367
+ let start: RegExpExecArray | null;
368
+ while ((start = NON_WHITESPACE.exec(span.text)) !== null) {
369
+ const startX = offsetX + start.index * charWidth;
370
+ let width =
371
+ (NON_WHITESPACE.lastIndex - start.index) *
372
+ charWidth;
373
+
374
+ // Reached the edge of the minimap
375
+ if (startX > context.canvas.width) {
376
+ break;
377
+ }
378
+
379
+ // Limit width to edge of minimap
380
+ if (startX + width > context.canvas.width) {
381
+ width = context.canvas.width - startX;
382
+ }
383
+
384
+ // Scaled 2px buffer between lines
385
+ const yBuffer = 2 / Scale.SizeRatio;
386
+ const height = lineHeight - yBuffer;
387
+
388
+ context.fillStyle = info.color;
389
+ context.globalAlpha = 0.65; // Make the blocks a bit faded
390
+ context.beginPath();
391
+ context.rect(startX, offsetY, width, height);
392
+ context.fill();
393
+ }
394
+
395
+ // Undo the fade so it cannot bleed into later layers.
396
+ context.globalAlpha = 1;
397
+
398
+ offsetX += span.text.length * charWidth;
399
+ break;
400
+ }
401
+ }
402
+ }
403
+ }
404
+
405
+ private getFontInfo(tags: string): FontInfo {
406
+ const cached = this._fontInfoMap.get(tags);
407
+ if (cached) {
408
+ return cached;
409
+ }
410
+
411
+ // Create a mock token wrapped in a cm-line
412
+ const mockToken = crelt("span", { class: tags });
413
+ const mockLine = crelt(
414
+ "div",
415
+ { class: "cm-line", style: "display: none" },
416
+ mockToken,
417
+ );
418
+ this.view.contentDOM.appendChild(mockLine);
419
+
420
+ // Get style information and store it
421
+ const style = window.getComputedStyle(mockToken);
422
+ const lineHeight = parseFloat(style.lineHeight) / Scale.SizeRatio;
423
+ const result = {
424
+ color: style.color,
425
+ font: `${style.fontStyle} ${style.fontWeight} ${lineHeight}px ${style.fontFamily}`,
426
+ lineHeight,
427
+ };
428
+ this._fontInfoMap.set(tags, result);
429
+
430
+ // Clean up and return
431
+ this.view.contentDOM.removeChild(mockLine);
432
+ return result;
433
+ }
434
+
435
+ private themeChanged(): boolean {
436
+ const previous = this._themeClasses;
437
+ const now = new Set(this.view.dom.classList.values());
438
+ this._themeClasses = now;
439
+
440
+ if (!previous) {
441
+ return true;
442
+ }
443
+
444
+ // Ignore certain classes being added/removed
445
+ previous.delete("cm-focused");
446
+ now.delete("cm-focused");
447
+
448
+ if (previous.size !== now.size) {
449
+ return true;
450
+ }
451
+
452
+ let containsAll = true;
453
+ previous.forEach((theme) => {
454
+ if (!now.has(theme)) {
455
+ containsAll = false;
456
+ }
457
+ });
458
+
459
+ return !containsAll;
460
+ }
461
+ }
462
+
463
+ export function text(view: EditorView): TextState {
464
+ return new TextState(view);
465
+ }
package/src/types.ts ADDED
@@ -0,0 +1,7 @@
1
+ export type DrawContext = {
2
+ context: CanvasRenderingContext2D;
3
+ offsetY: number;
4
+ lineHeight: number;
5
+ charWidth: number;
6
+ offsetX: number;
7
+ };