@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/index.ts ADDED
@@ -0,0 +1,368 @@
1
+ import { Facet } from "@codemirror/state";
2
+ import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view";
3
+ import { Overlay } from "./Overlay.js";
4
+ import { Config, Options, Scale } from "./Config.js";
5
+ import { DiagnosticState, diagnostics } from "./diagnostics.js";
6
+ import { SelectionState, selections } from "./selections.js";
7
+ import { TextState, text } from "./text.js";
8
+ import { LinesState } from "./LinesState.js";
9
+ import crelt from "crelt";
10
+ import { GUTTER_WIDTH, drawLineGutter } from "./Gutters.js";
11
+
12
+ const Theme = EditorView.theme({
13
+ "&": {
14
+ height: "100%",
15
+ overflowY: "auto",
16
+ },
17
+ "& .cm-minimap-gutter": {
18
+ borderRight: 0,
19
+ flexShrink: 0,
20
+ left: "unset",
21
+ position: "sticky",
22
+ right: 0,
23
+ top: 0,
24
+ },
25
+ "& .cm-minimap-autohide": {
26
+ opacity: 0.0,
27
+ transition: "opacity 0.3s",
28
+ },
29
+ "& .cm-minimap-autohide:hover": {
30
+ opacity: 1.0,
31
+ },
32
+ "& .cm-minimap-inner": {
33
+ height: "100%",
34
+ position: "absolute",
35
+ right: 0,
36
+ top: 0,
37
+ overflowY: "hidden",
38
+ "& canvas": {
39
+ display: "block",
40
+ },
41
+ },
42
+ "& .cm-minimap-box-shadow": {
43
+ boxShadow: "12px 0px 20px 5px #6c6c6c",
44
+ },
45
+ // Suppresses the scrollbar's appearance only; the scroller keeps its
46
+ // overflow, so wheel, trackpad, keyboard and programmatic scrolling all
47
+ // continue to work.
48
+ // Lives on the scroller rather than the editor root: `themeChanged`
49
+ // fingerprints the root's class list, so toggling a class there would read
50
+ // as a theme change and needlessly drop the font metric cache.
51
+ "& .cm-scroller.cm-minimap-hide-scrollbar": {
52
+ scrollbarWidth: "none",
53
+ "&::-webkit-scrollbar": {
54
+ display: "none",
55
+ },
56
+ },
57
+ });
58
+
59
+ const HIDE_SCROLLBAR_CLASS = "cm-minimap-hide-scrollbar";
60
+
61
+ const WIDTH_RATIO = 6;
62
+
63
+ const minimapClass = ViewPlugin.fromClass(
64
+ class {
65
+ private dom: HTMLElement | undefined;
66
+ private inner: HTMLElement | undefined;
67
+ private canvas: HTMLCanvasElement | undefined;
68
+
69
+ public text: TextState;
70
+ public selection: SelectionState;
71
+ public diagnostic: DiagnosticState;
72
+
73
+ public constructor(private view: EditorView) {
74
+ this.text = text(view);
75
+ this.selection = selections(view);
76
+ this.diagnostic = diagnostics(view);
77
+
78
+ if (view.state.facet(showMinimap)) {
79
+ this.create(view);
80
+ }
81
+ }
82
+
83
+ private create(view: EditorView) {
84
+ const config = view.state.facet(showMinimap);
85
+ if (!config) {
86
+ throw Error("Expected nonnull");
87
+ }
88
+
89
+ this.inner = crelt("div", { class: "cm-minimap-inner" });
90
+ this.canvas = crelt("canvas");
91
+
92
+ this.dom = config.create(view).dom;
93
+ this.dom.classList.add("cm-gutters");
94
+ this.dom.classList.add("cm-minimap-gutter");
95
+
96
+ this.inner.appendChild(this.canvas);
97
+ this.dom.appendChild(this.inner);
98
+
99
+ // For now let's keep this same behavior. We might want to change
100
+ // this in the future and have the extension figure out how to mount.
101
+ // Or expose some more generic right gutter api and use that
102
+ this.view.scrollDOM.insertBefore(
103
+ this.dom,
104
+ this.view.contentDOM.nextSibling,
105
+ );
106
+
107
+ for (const key in this.view.state.facet(Config).eventHandlers) {
108
+ const handler =
109
+ this.view.state.facet(Config).eventHandlers[key];
110
+ if (handler) {
111
+ this.dom.addEventListener(key, (e) =>
112
+ handler(e, this.view),
113
+ );
114
+ }
115
+ }
116
+
117
+ if (config.autohide) {
118
+ this.dom.classList.add("cm-minimap-autohide");
119
+ }
120
+ }
121
+
122
+ private remove() {
123
+ this.view.scrollDOM.classList.remove(HIDE_SCROLLBAR_CLASS);
124
+
125
+ if (this.dom) {
126
+ this.dom.remove();
127
+ }
128
+ }
129
+
130
+ update(update: ViewUpdate) {
131
+ const prev = update.startState.facet(showMinimap);
132
+ const now = update.state.facet(showMinimap);
133
+
134
+ if (prev && !now) {
135
+ this.remove();
136
+ return;
137
+ }
138
+
139
+ if (!prev && now) {
140
+ this.create(update.view);
141
+ }
142
+
143
+ if (now) {
144
+ this.text.update(update);
145
+ this.selection.update(update);
146
+ this.diagnostic.update(update);
147
+ this.render();
148
+ }
149
+ }
150
+
151
+ getWidth(): number {
152
+ const maxWidth = this.view.state.facet(Config).width;
153
+ const editorWidth = this.view.dom.clientWidth;
154
+
155
+ // Shrink proportionally rather than letting the minimap take over
156
+ // a narrow editor.
157
+ if (editorWidth <= maxWidth * WIDTH_RATIO) {
158
+ return maxWidth * (editorWidth / (maxWidth * WIDTH_RATIO));
159
+ }
160
+
161
+ return maxWidth;
162
+ }
163
+
164
+ render() {
165
+ // If we don't have elements to draw to exit early
166
+ if (!this.dom || !this.canvas || !this.inner) {
167
+ return;
168
+ }
169
+
170
+ this.updateBoxShadow();
171
+ this.view.scrollDOM.classList.toggle(
172
+ HIDE_SCROLLBAR_CLASS,
173
+ this.view.state.facet(Config).hideScrollbar,
174
+ );
175
+
176
+ const width = this.getWidth();
177
+ const domHeight = this.view.dom.getBoundingClientRect().height;
178
+
179
+ this.dom.style.width = width + "px";
180
+ this.canvas.style.maxWidth = width + "px";
181
+ this.inner.style.minHeight = domHeight + "px";
182
+ this.canvas.style.height = domHeight + "px";
183
+
184
+ /**
185
+ * Assigning to `width`/`height` reallocates the backing buffer and
186
+ * resets every canvas property, so only do it on an actual size
187
+ * change. The reset doubles as the clear when it does happen.
188
+ */
189
+ const canvasWidth = width * Scale.PixelMultiplier;
190
+ const canvasHeight = domHeight * Scale.PixelMultiplier;
191
+ const resized =
192
+ this.canvas.width !== canvasWidth ||
193
+ this.canvas.height !== canvasHeight;
194
+
195
+ if (resized) {
196
+ this.canvas.width = canvasWidth;
197
+ this.canvas.height = canvasHeight;
198
+ }
199
+
200
+ const context = this.canvas.getContext("2d");
201
+ if (!context) {
202
+ return;
203
+ }
204
+
205
+ if (!resized) {
206
+ context.clearRect(0, 0, canvasWidth, canvasHeight);
207
+ }
208
+
209
+ /**
210
+ * Draw layers leave `globalAlpha` faded behind them. Resizing used
211
+ * to reset it as a side effect of reallocating the canvas; now that
212
+ * the resize is conditional, reset it explicitly instead.
213
+ */
214
+ context.globalAlpha = 1;
215
+
216
+ /* We need to get the correct font dimensions before this to measure characters */
217
+ const { charWidth, lineHeight } = this.text.measure(context);
218
+
219
+ let { startIndex, endIndex, offsetY } = this.canvasStartAndEndIndex(
220
+ context,
221
+ lineHeight,
222
+ );
223
+
224
+ const gutters = this.view.state.facet(Config).gutters;
225
+ const lineCount = this.view.state.field(LinesState).length;
226
+
227
+ for (let i = startIndex; i < endIndex; i++) {
228
+ if (i >= lineCount) break;
229
+
230
+ const drawContext = {
231
+ offsetX: 0,
232
+ offsetY,
233
+ context,
234
+ lineHeight,
235
+ charWidth,
236
+ };
237
+
238
+ if (gutters.length) {
239
+ /* Small leading buffer */
240
+ drawContext.offsetX += 2;
241
+
242
+ for (const gutter of gutters) {
243
+ drawLineGutter(gutter, drawContext, i + 1);
244
+ drawContext.offsetX += GUTTER_WIDTH;
245
+ }
246
+
247
+ /* Small trailing buffer */
248
+ drawContext.offsetX += 2;
249
+ }
250
+
251
+ this.text.drawLine(drawContext, i + 1);
252
+ this.selection.drawLine(drawContext, i + 1);
253
+ this.diagnostic.drawLine(drawContext, i + 1);
254
+
255
+ offsetY += lineHeight;
256
+ }
257
+
258
+ context.restore();
259
+ }
260
+
261
+ private canvasStartAndEndIndex(
262
+ context: CanvasRenderingContext2D,
263
+ lineHeight: number,
264
+ ) {
265
+ const { top: rawTop, bottom: rawBottom } =
266
+ this.view.documentPadding;
267
+ const pTop = rawTop / Scale.SizeRatio;
268
+ const pBottom = rawBottom / Scale.SizeRatio;
269
+
270
+ const canvasHeight = context.canvas.height;
271
+ const { clientHeight, scrollHeight, scrollTop } =
272
+ this.view.scrollDOM;
273
+ let scrollPercent = scrollTop / (scrollHeight - clientHeight);
274
+ if (isNaN(scrollPercent)) {
275
+ scrollPercent = 0;
276
+ }
277
+
278
+ const lineCount = this.view.state.field(LinesState).length;
279
+ const totalHeight = pTop + pBottom + lineCount * lineHeight;
280
+
281
+ const canvasTop = Math.max(
282
+ 0,
283
+ scrollPercent * (totalHeight - canvasHeight),
284
+ );
285
+ const offsetY = Math.max(0, pTop - canvasTop);
286
+
287
+ const startIndex = Math.round(
288
+ Math.max(0, canvasTop - pTop) / lineHeight,
289
+ );
290
+ const spaceForLines = Math.round(
291
+ (canvasHeight - offsetY) / lineHeight,
292
+ );
293
+
294
+ return {
295
+ startIndex,
296
+ endIndex: startIndex + spaceForLines,
297
+ offsetY,
298
+ };
299
+ }
300
+
301
+ private updateBoxShadow() {
302
+ if (!this.canvas) {
303
+ return;
304
+ }
305
+
306
+ const { clientWidth, scrollWidth, scrollLeft } =
307
+ this.view.scrollDOM;
308
+
309
+ if (clientWidth + scrollLeft < scrollWidth) {
310
+ this.canvas.classList.add("cm-minimap-box-shadow");
311
+ } else {
312
+ this.canvas.classList.remove("cm-minimap-box-shadow");
313
+ }
314
+ }
315
+
316
+ destroy() {
317
+ this.text.destroy();
318
+ this.remove();
319
+ }
320
+ },
321
+ {
322
+ eventHandlers: {
323
+ scroll() {
324
+ requestAnimationFrame(() => this.render());
325
+ },
326
+ },
327
+ provide: (plugin) => {
328
+ return EditorView.scrollMargins.of((view) => {
329
+ const width = view.plugin(plugin)?.getWidth();
330
+ if (!width) {
331
+ return null;
332
+ }
333
+
334
+ return { right: width };
335
+ });
336
+ },
337
+ },
338
+ );
339
+
340
+ export interface MinimapConfig extends Omit<Options, "enabled"> {
341
+ /**
342
+ * A function that creates the element that contains the minimap
343
+ */
344
+ create: (view: EditorView) => { dom: HTMLElement };
345
+ }
346
+
347
+ /**
348
+ * Facet used to show a minimap in the right gutter of the editor using the
349
+ * provided configuration.
350
+ *
351
+ * If you return `null`, a minimap will not be shown.
352
+ */
353
+ const showMinimap = Facet.define<MinimapConfig | null, MinimapConfig | null>({
354
+ combine: (c) => c.find((o) => o !== null) ?? null,
355
+ enables: (f) => {
356
+ return [
357
+ [
358
+ Config.compute([f], (s) => s.facet(f)),
359
+ Theme,
360
+ LinesState,
361
+ minimapClass, // TODO, codemirror-ify this one better
362
+ Overlay,
363
+ ],
364
+ ];
365
+ },
366
+ });
367
+
368
+ export { showMinimap };
@@ -0,0 +1,21 @@
1
+ import { EditorView } from "@codemirror/view";
2
+
3
+ // TODO: renamed this file because something's weird with codemirror build
4
+
5
+ export abstract class LineBasedState<TValue> {
6
+ protected map: Map<number, TValue>;
7
+ protected view: EditorView;
8
+
9
+ public constructor(view: EditorView) {
10
+ this.map = new Map();
11
+ this.view = view;
12
+ }
13
+
14
+ public get(lineNumber: number): TValue | undefined {
15
+ return this.map.get(lineNumber);
16
+ }
17
+
18
+ protected set(lineNumber: number, value: TValue) {
19
+ this.map.set(lineNumber, value);
20
+ }
21
+ }
@@ -0,0 +1,212 @@
1
+ import { LineBasedState } from "./linebasedstate.js";
2
+ import { EditorView, ViewUpdate } from "@codemirror/view";
3
+ import { LinesState, foldsChanged } from "./LinesState.js";
4
+ import { DrawContext } from "./types.js";
5
+ import { Config } from "./Config.js";
6
+
7
+ type Selection = { from: number; to: number; extends: boolean };
8
+ type DrawInfo = { backgroundColor: string };
9
+
10
+ export class SelectionState extends LineBasedState<Array<Selection>> {
11
+ private _drawInfo: DrawInfo | undefined;
12
+ private _themeClasses: string;
13
+
14
+ public constructor(view: EditorView) {
15
+ super(view);
16
+
17
+ this.getDrawInfo();
18
+ this._themeClasses = view.dom.classList.value;
19
+ }
20
+
21
+ private shouldUpdate(update: ViewUpdate) {
22
+ // If the minimap is disabled
23
+ if (!update.state.facet(Config).enabled) {
24
+ return false;
25
+ }
26
+
27
+ // If the doc changed
28
+ if (update.docChanged) {
29
+ return true;
30
+ }
31
+
32
+ // If the selection changed
33
+ if (update.selectionSet) {
34
+ return true;
35
+ }
36
+
37
+ // If the theme changed
38
+ if (this._themeClasses !== this.view.dom.classList.value) {
39
+ return true;
40
+ }
41
+
42
+ // If the folds changed
43
+ if (foldsChanged(update.transactions)) {
44
+ return true;
45
+ }
46
+
47
+ return false;
48
+ }
49
+
50
+ public update(update: ViewUpdate) {
51
+ if (!this.shouldUpdate(update)) {
52
+ return;
53
+ }
54
+
55
+ this.map.clear();
56
+
57
+ /* If class list has changed, clear and recalculate the selection style */
58
+ if (this._themeClasses !== this.view.dom.classList.value) {
59
+ this._drawInfo = undefined;
60
+ this._themeClasses = this.view.dom.classList.value;
61
+ }
62
+
63
+ const { ranges } = update.state.selection;
64
+
65
+ let selectionIndex = 0;
66
+ for (const [index, line] of update.state.field(LinesState).entries()) {
67
+ const selections: Array<Selection> = [];
68
+
69
+ let offset = 0;
70
+ for (const span of line) {
71
+ do {
72
+ // We've already processed all selections
73
+ if (selectionIndex >= ranges.length) {
74
+ continue;
75
+ }
76
+
77
+ // The next selection begins after this span
78
+ if (span.to < ranges[selectionIndex].from) {
79
+ continue;
80
+ }
81
+
82
+ // Ignore 0-length selections
83
+ if (
84
+ ranges[selectionIndex].from ===
85
+ ranges[selectionIndex].to
86
+ ) {
87
+ selectionIndex++;
88
+ continue;
89
+ }
90
+
91
+ // Build the selection for the current span
92
+ const range = ranges[selectionIndex];
93
+ const selection = {
94
+ from:
95
+ offset +
96
+ Math.max(span.from, range.from) -
97
+ span.from,
98
+ to: offset + Math.min(span.to, range.to) - span.from,
99
+ extends: range.to > span.to,
100
+ };
101
+
102
+ const lastSelection = selections.slice(-1)[0];
103
+ if (lastSelection && lastSelection.to === selection.from) {
104
+ // The selection in this span may just be a continuation of the
105
+ // selection in the previous span
106
+
107
+ // Adjust `to` depending on if we're in a folded span
108
+ let { to } = selection;
109
+ if (span.folded && selection.extends) {
110
+ to = selection.from + 1;
111
+ } else if (span.folded && !selection.extends) {
112
+ to = lastSelection.to;
113
+ }
114
+
115
+ selections[selections.length - 1] = {
116
+ ...lastSelection,
117
+ to,
118
+ extends: selection.extends,
119
+ };
120
+ } else if (!span.folded) {
121
+ // It's a new selection; if we're not in a folded span we
122
+ // should push it onto the stack
123
+ selections.push(selection);
124
+ }
125
+
126
+ // If the selection doesn't end in this span, break out of the loop
127
+ if (selection.extends) {
128
+ break;
129
+ }
130
+
131
+ // Otherwise, move to the next selection
132
+ selectionIndex++;
133
+ } while (
134
+ selectionIndex < ranges.length &&
135
+ span.to >= ranges[selectionIndex].from
136
+ );
137
+
138
+ offset += span.folded ? 1 : span.to - span.from;
139
+ }
140
+
141
+ // If we don't have any selections on this line, we don't need to store anything
142
+ if (selections.length === 0) {
143
+ continue;
144
+ }
145
+
146
+ // Lines are indexed beginning at 1 instead of 0
147
+ const lineNumber = index + 1;
148
+ this.map.set(lineNumber, selections);
149
+ }
150
+ }
151
+
152
+ public drawLine(ctx: DrawContext, lineNumber: number) {
153
+ const {
154
+ context,
155
+ lineHeight,
156
+ charWidth,
157
+ offsetX: startOffsetX,
158
+ offsetY,
159
+ } = ctx;
160
+ const selections = this.get(lineNumber);
161
+ if (!selections) {
162
+ return;
163
+ }
164
+
165
+ for (const selection of selections) {
166
+ const offsetX = startOffsetX + selection.from * charWidth;
167
+ const textWidth = (selection.to - selection.from) * charWidth;
168
+ const fullWidth = context.canvas.width - offsetX;
169
+
170
+ if (selection.extends) {
171
+ // Draw the full width rectangle in the background
172
+ context.globalAlpha = 0.65;
173
+ context.beginPath();
174
+ context.rect(offsetX, offsetY, fullWidth, lineHeight);
175
+ context.fillStyle = this.getDrawInfo().backgroundColor;
176
+ context.fill();
177
+ }
178
+
179
+ // Draw text selection rectangle in the foreground
180
+ context.globalAlpha = 1;
181
+ context.beginPath();
182
+ context.rect(offsetX, offsetY, textWidth, lineHeight);
183
+ context.fillStyle = this.getDrawInfo().backgroundColor;
184
+ context.fill();
185
+ }
186
+ }
187
+
188
+ private getDrawInfo(): DrawInfo {
189
+ if (this._drawInfo) {
190
+ return this._drawInfo;
191
+ }
192
+
193
+ // Create a mock selection
194
+ const mockToken = document.createElement("span");
195
+ mockToken.setAttribute("class", "cm-selectionBackground");
196
+ this.view.dom.appendChild(mockToken);
197
+
198
+ // Get style information
199
+ const style = window.getComputedStyle(mockToken);
200
+ const result = { backgroundColor: style.backgroundColor };
201
+
202
+ // Store the result for the next update
203
+ this._drawInfo = result;
204
+ this.view.dom.removeChild(mockToken);
205
+
206
+ return result;
207
+ }
208
+ }
209
+
210
+ export function selections(view: EditorView): SelectionState {
211
+ return new SelectionState(view);
212
+ }