@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.
@@ -0,0 +1,96 @@
1
+ import { foldEffect, foldedRanges, unfoldEffect } from "@codemirror/language";
2
+ import { StateField, EditorState, Transaction } from "@codemirror/state";
3
+ import { Config } from "./Config.js";
4
+
5
+ type Span = { from: number; to: number; folded: boolean };
6
+ type Line = Array<Span>;
7
+ type Lines = Array<Line>;
8
+
9
+ function computeLinesState(state: EditorState): Lines {
10
+ if (!state.facet(Config).enabled) {
11
+ return [];
12
+ }
13
+
14
+ const lines: Lines = [];
15
+
16
+ const lineCursor = state.doc.iterLines();
17
+ const foldedRangeCursor = foldedRanges(state).iter();
18
+
19
+ let textOffset = 0;
20
+ lineCursor.next();
21
+
22
+ while (!lineCursor.done) {
23
+ const lineText = lineCursor.value;
24
+ const from = textOffset;
25
+ const to = from + lineText.length;
26
+
27
+ // Iterate through folded ranges until we're at or past the current line
28
+ while (foldedRangeCursor.value && foldedRangeCursor.to < from) {
29
+ foldedRangeCursor.next();
30
+ }
31
+ const { from: foldFrom, to: foldTo } = foldedRangeCursor;
32
+
33
+ const lineStartInFold = from >= foldFrom && from < foldTo;
34
+ const lineEndsInFold = to > foldFrom && to <= foldTo;
35
+
36
+ if (lineStartInFold) {
37
+ const lastLine = lines.pop() ?? [];
38
+ const lastRange = lastLine.pop();
39
+
40
+ // If the last range is folded, we extend the folded range
41
+ if (lastRange && lastRange.folded) {
42
+ lastRange.to = foldTo;
43
+ }
44
+
45
+ // If we popped the last range, add it back
46
+ if (lastRange) {
47
+ lastLine.push(lastRange);
48
+ }
49
+
50
+ // If we didn't have a previous range, or the previous range wasn't folded add a new range
51
+ if (!lastRange || !lastRange.folded) {
52
+ lastLine.push({ from: foldFrom, to: foldTo, folded: true });
53
+ }
54
+
55
+ // If the line doesn't end in a fold, we add another token for the unfolded section
56
+ if (!lineEndsInFold) {
57
+ lastLine.push({ from: foldTo, to, folded: false });
58
+ }
59
+
60
+ lines.push(lastLine);
61
+ } else if (lineEndsInFold) {
62
+ lines.push([
63
+ { from, to: foldFrom, folded: false },
64
+ { from: foldFrom, to: foldTo, folded: true },
65
+ ]);
66
+ } else {
67
+ lines.push([{ from, to, folded: false }]);
68
+ }
69
+
70
+ textOffset = to + 1;
71
+ lineCursor.next();
72
+ }
73
+
74
+ return lines;
75
+ }
76
+
77
+ const LinesState = StateField.define<Lines>({
78
+ create: (state) => computeLinesState(state),
79
+ update: (current, tr) => {
80
+ if (foldsChanged([tr]) || tr.docChanged) {
81
+ return computeLinesState(tr.state);
82
+ }
83
+
84
+ return current;
85
+ },
86
+ });
87
+
88
+ /** Returns if the folds have changed in this update */
89
+ function foldsChanged(transactions: readonly Transaction[]): boolean {
90
+ return transactions.some((tr) =>
91
+ tr.effects.some((ef) => ef.is(foldEffect) || ef.is(unfoldEffect)),
92
+ );
93
+ }
94
+
95
+ export { foldsChanged, LinesState };
96
+ export type { Lines };
package/src/Overlay.ts ADDED
@@ -0,0 +1,320 @@
1
+ import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view";
2
+ import { Config, Scale } from "./Config.js";
3
+ import crelt from "crelt";
4
+
5
+ const Theme = EditorView.theme({
6
+ ".cm-minimap-overlay-container": {
7
+ position: "absolute",
8
+ top: 0,
9
+ height: "100%",
10
+ width: "100%",
11
+ "&.cm-minimap-overlay-mouse-over": {
12
+ opacity: 0,
13
+ transition: "visibility 0s linear 300ms, opacity 300ms",
14
+ },
15
+ "&.cm-minimap-overlay-mouse-over:hover": {
16
+ opacity: 1,
17
+ transition: "visibility 0s linear 0ms, opacity 300ms",
18
+ },
19
+ "&.cm-minimap-overlay-off": {
20
+ display: "none",
21
+ },
22
+ "& .cm-minimap-overlay": {
23
+ background: "rgb(121, 121, 121)",
24
+ opacity: "0.2",
25
+ position: "absolute",
26
+ right: 0,
27
+ top: 0,
28
+ width: "100%",
29
+ transition: "top 0s ease-in 0ms",
30
+ "&:hover": {
31
+ opacity: "0.3",
32
+ },
33
+ },
34
+ "&.cm-minimap-overlay-active": {
35
+ opacity: 1,
36
+ visibility: "visible",
37
+ transition: "visibility 0s linear 0ms, opacity 300ms",
38
+ "& .cm-minimap-overlay": {
39
+ opacity: "0.4",
40
+ },
41
+ },
42
+ },
43
+ });
44
+
45
+ const SCALE = Scale.PixelMultiplier * Scale.SizeRatio;
46
+
47
+ const OverlayView = ViewPlugin.fromClass(
48
+ class {
49
+ private container: HTMLElement | undefined;
50
+ private dom: HTMLElement | undefined;
51
+
52
+ private _isDragging: boolean = false;
53
+ private _dragStartY: number | undefined;
54
+
55
+ // Bound once so that removeEventListener sees the same reference that
56
+ // addEventListener was given; `.bind` at call time yields a new
57
+ // function each call and the listener would never be detached.
58
+ private readonly boundMouseDown = this.onMouseDown.bind(this);
59
+ private readonly boundMouseUp = this.onMouseUp.bind(this);
60
+ private readonly boundMouseMove = this.onMouseMove.bind(this);
61
+
62
+ public constructor(private view: EditorView) {
63
+ if (view.state.facet(Config).enabled) {
64
+ this.create(view);
65
+ }
66
+ }
67
+
68
+ private create(view: EditorView) {
69
+ this.container = crelt("div", {
70
+ class: "cm-minimap-overlay-container",
71
+ });
72
+ this.dom = crelt("div", { class: "cm-minimap-overlay" });
73
+ this.container.appendChild(this.dom);
74
+
75
+ // Attach event listeners for overlay
76
+ this.container.addEventListener("mousedown", this.boundMouseDown);
77
+ window.addEventListener("mouseup", this.boundMouseUp);
78
+ window.addEventListener("mousemove", this.boundMouseMove);
79
+
80
+ // Attach the overlay elements to the minimap
81
+ const inner = view.dom.querySelector(".cm-minimap-inner");
82
+ if (inner) {
83
+ inner.appendChild(this.container);
84
+ }
85
+
86
+ // Initially set overlay configuration styles, height, top
87
+ this.computeShowOverlay();
88
+ this.computeHeight();
89
+ this.computeTop();
90
+ }
91
+
92
+ private remove() {
93
+ if (this.container) {
94
+ this.container.removeEventListener(
95
+ "mousedown",
96
+ this.boundMouseDown,
97
+ );
98
+ window.removeEventListener("mouseup", this.boundMouseUp);
99
+ window.removeEventListener("mousemove", this.boundMouseMove);
100
+ this.container.remove();
101
+ }
102
+ }
103
+
104
+ update(update: ViewUpdate) {
105
+ const prev = update.startState.facet(Config).enabled;
106
+ const now = update.state.facet(Config).enabled;
107
+
108
+ if (prev && !now) {
109
+ this.remove();
110
+ return;
111
+ }
112
+
113
+ if (!prev && now) {
114
+ this.create(update.view);
115
+ }
116
+
117
+ if (now) {
118
+ this.computeShowOverlay();
119
+
120
+ if (update.geometryChanged) {
121
+ this.computeHeight();
122
+ this.computeTop();
123
+ }
124
+ }
125
+ }
126
+
127
+ public computeHeight() {
128
+ if (!this.dom) {
129
+ return;
130
+ }
131
+
132
+ const height = this.view.dom.clientHeight / SCALE;
133
+ this.dom.style.height = height + "px";
134
+ }
135
+
136
+ public computeTop() {
137
+ if (!this._isDragging && this.dom) {
138
+ const { clientHeight, scrollHeight, scrollTop } =
139
+ this.view.scrollDOM;
140
+
141
+ const maxScrollTop = scrollHeight - clientHeight;
142
+ const topForNonOverflowing = scrollTop / SCALE;
143
+
144
+ const height = clientHeight / SCALE;
145
+ const maxTop = clientHeight - height;
146
+ let scrollRatio = scrollTop / maxScrollTop;
147
+ if (isNaN(scrollRatio)) scrollRatio = 0;
148
+ const topForOverflowing = maxTop * scrollRatio;
149
+
150
+ const top = Math.min(topForOverflowing, topForNonOverflowing);
151
+ this.dom.style.top = top + "px";
152
+ }
153
+ }
154
+
155
+ public computeShowOverlay() {
156
+ if (!this.container) {
157
+ return;
158
+ }
159
+
160
+ const { showOverlay } = this.view.state.facet(Config);
161
+
162
+ if (showOverlay === "mouse-over") {
163
+ this.container.classList.add("cm-minimap-overlay-mouse-over");
164
+ } else {
165
+ this.container.classList.remove(
166
+ "cm-minimap-overlay-mouse-over",
167
+ );
168
+ }
169
+
170
+ const { clientHeight, scrollHeight } = this.view.scrollDOM;
171
+ if (clientHeight === scrollHeight) {
172
+ this.container.classList.add("cm-minimap-overlay-off");
173
+ } else {
174
+ this.container.classList.remove("cm-minimap-overlay-off");
175
+ }
176
+ }
177
+
178
+ private onMouseDown(event: MouseEvent) {
179
+ if (!this.container) {
180
+ return;
181
+ }
182
+
183
+ // Ignore right click
184
+ if (event.button === 2) {
185
+ return;
186
+ }
187
+
188
+ // If target is the overlay start dragging
189
+ const { clientY, target } = event;
190
+ if (target === this.dom) {
191
+ this._dragStartY = event.clientY;
192
+ this._isDragging = true;
193
+ this.container.classList.add("cm-minimap-overlay-active");
194
+ return;
195
+ }
196
+
197
+ // Updates the scroll position of the EditorView based on the
198
+ // position of the MouseEvent on the minimap canvas
199
+ const { clientHeight, scrollHeight, scrollTop } =
200
+ this.view.scrollDOM;
201
+ const targetTop = (target as HTMLElement).getBoundingClientRect()
202
+ .top;
203
+ const deltaY = (clientY - targetTop) * SCALE;
204
+
205
+ const scrollRatio = scrollTop / (scrollHeight - clientHeight);
206
+ const visibleRange = clientHeight * SCALE - clientHeight;
207
+ const visibleTop = visibleRange * scrollRatio;
208
+
209
+ const top = Math.max(0, scrollTop - visibleTop);
210
+ this.view.scrollDOM.scrollTop = top + deltaY - clientHeight / 2;
211
+ }
212
+
213
+ private onMouseUp(_event: MouseEvent) {
214
+ // Stop dragging on mouseup
215
+ if (this._isDragging && this.container) {
216
+ this._dragStartY = undefined;
217
+ this._isDragging = false;
218
+ this.container.classList.remove("cm-minimap-overlay-active");
219
+ }
220
+ }
221
+
222
+ private onMouseMove(event: MouseEvent) {
223
+ if (!this._isDragging || !this.dom) {
224
+ return;
225
+ }
226
+
227
+ event.preventDefault();
228
+ event.stopPropagation();
229
+
230
+ // Without an existing position, we're just beginning to drag.
231
+ if (!this._dragStartY) {
232
+ this._dragStartY = event.clientY;
233
+ return;
234
+ }
235
+
236
+ const deltaY = event.clientY - this._dragStartY;
237
+ const movingUp = deltaY < 0;
238
+ const movingDown = deltaY > 0;
239
+
240
+ // Update drag position for the next tick
241
+ this._dragStartY = event.clientY;
242
+
243
+ const canvasHeight = this.dom.getBoundingClientRect().height;
244
+ const canvasAbsTop = this.dom.getBoundingClientRect().y;
245
+ const canvasAbsBot = canvasAbsTop + canvasHeight;
246
+ const canvasRelTopDouble = parseFloat(this.dom.style.top);
247
+
248
+ const scrollPosition = this.view.scrollDOM.scrollTop;
249
+ const editorHeight = this.view.scrollDOM.clientHeight;
250
+ const contentHeight = this.view.scrollDOM.scrollHeight;
251
+
252
+ const atTop = scrollPosition === 0;
253
+ const atBottom =
254
+ Math.round(scrollPosition) >=
255
+ Math.round(contentHeight - editorHeight);
256
+
257
+ // We allow over-dragging past the top/bottom, but the overlay just sticks
258
+ // to the top or bottom of its range. These checks prevent us from immediately
259
+ // moving the overlay when the drag changes direction. We should wait until
260
+ // the cursor has returned to, and begun to pass the bottom/top of the range
261
+ if (
262
+ (atTop && movingUp) ||
263
+ (atTop && event.clientY < canvasAbsTop)
264
+ ) {
265
+ return;
266
+ }
267
+ if (
268
+ (atBottom && movingDown) ||
269
+ (atBottom && event.clientY > canvasAbsBot)
270
+ ) {
271
+ return;
272
+ }
273
+
274
+ // Set view scroll directly
275
+ const scrollHeight = this.view.scrollDOM.scrollHeight;
276
+ const clientHeight = this.view.scrollDOM.clientHeight;
277
+
278
+ const maxTopNonOverflowing = (scrollHeight - clientHeight) / SCALE;
279
+ const maxTopOverflowing = clientHeight - clientHeight / SCALE;
280
+
281
+ const change = canvasRelTopDouble + deltaY;
282
+
283
+ /**
284
+ * ScrollPosOverflowing is calculated by:
285
+ * - Calculating the offset (change) relative to the total height of the container
286
+ * - Multiplying by the maximum scrollTop position for the scroller
287
+ * - The maximum scrollTop position for the scroller is the total scroll height minus the client height
288
+ */
289
+ const relativeToMax = change / maxTopOverflowing;
290
+ const scrollPosOverflowing =
291
+ (scrollHeight - clientHeight) * relativeToMax;
292
+
293
+ const scrollPosNonOverflowing = change * SCALE;
294
+ this.view.scrollDOM.scrollTop = Math.max(
295
+ scrollPosOverflowing,
296
+ scrollPosNonOverflowing,
297
+ );
298
+
299
+ // view.scrollDOM truncates if out of bounds. We need to mimic that behavior here with min/max guard
300
+ const top = Math.min(
301
+ Math.max(0, change),
302
+ Math.min(maxTopOverflowing, maxTopNonOverflowing),
303
+ );
304
+ this.dom.style.top = top + "px";
305
+ }
306
+
307
+ public destroy() {
308
+ this.remove();
309
+ }
310
+ },
311
+ {
312
+ eventHandlers: {
313
+ scroll() {
314
+ requestAnimationFrame(() => this.computeTop());
315
+ },
316
+ },
317
+ },
318
+ );
319
+
320
+ export const Overlay = [Theme, OverlayView];
@@ -0,0 +1,171 @@
1
+ import { EditorView, ViewUpdate } from "@codemirror/view";
2
+ import {
3
+ Diagnostic,
4
+ diagnosticCount,
5
+ forEachDiagnostic,
6
+ setDiagnosticsEffect,
7
+ } from "@codemirror/lint";
8
+
9
+ import { LineBasedState } from "./linebasedstate.js";
10
+ import { DrawContext } from "./types.js";
11
+ import { Lines, LinesState, foldsChanged } from "./LinesState.js";
12
+ import { Config } from "./Config.js";
13
+
14
+ type Severity = Diagnostic["severity"];
15
+
16
+ export class DiagnosticState extends LineBasedState<Severity> {
17
+ private count: number | undefined = undefined;
18
+
19
+ public constructor(view: EditorView) {
20
+ super(view);
21
+ }
22
+
23
+ private shouldUpdate(update: ViewUpdate) {
24
+ // If the minimap is disabled
25
+ if (!update.state.facet(Config).enabled) {
26
+ return false;
27
+ }
28
+
29
+ // If the doc changed
30
+ if (update.docChanged) {
31
+ return true;
32
+ }
33
+
34
+ // If the diagnostics changed
35
+ for (const tr of update.transactions) {
36
+ for (const ef of tr.effects) {
37
+ if (ef.is(setDiagnosticsEffect)) {
38
+ return true;
39
+ }
40
+ }
41
+ }
42
+
43
+ // If the folds changed
44
+ if (foldsChanged(update.transactions)) {
45
+ return true;
46
+ }
47
+
48
+ // If the minimap was previously hidden
49
+ if (this.count === undefined) {
50
+ return true;
51
+ }
52
+
53
+ return false;
54
+ }
55
+
56
+ public update(update: ViewUpdate) {
57
+ if (!this.shouldUpdate(update)) {
58
+ return;
59
+ }
60
+
61
+ this.map.clear();
62
+ const lines = update.state.field(LinesState);
63
+ this.count = diagnosticCount(update.state);
64
+
65
+ forEachDiagnostic(update.state, (diagnostic, from, to) => {
66
+ // Find the start and end lines for the diagnostic
67
+ const lineStart = this.findLine(from, lines);
68
+ const lineEnd = this.findLine(to, lines);
69
+
70
+ // Populate each line in the range with the highest severity diagnostic
71
+ let severity = diagnostic.severity;
72
+ for (let i = lineStart; i <= lineEnd; i++) {
73
+ const previous = this.get(i);
74
+ if (previous) {
75
+ severity = [severity, previous]
76
+ .sort(this.sort.bind(this))
77
+ .slice(0, 1)[0];
78
+ }
79
+ this.set(i, severity);
80
+ }
81
+ });
82
+ }
83
+
84
+ public drawLine(ctx: DrawContext, lineNumber: number) {
85
+ const { context, lineHeight, offsetX, offsetY } = ctx;
86
+ const severity = this.get(lineNumber);
87
+ if (!severity) {
88
+ return;
89
+ }
90
+
91
+ // Draw the full line width rectangle in the background
92
+ context.globalAlpha = 0.65;
93
+ context.beginPath();
94
+ context.rect(
95
+ offsetX,
96
+ offsetY /* TODO Scaling causes anti-aliasing in rectangles */,
97
+ context.canvas.width - offsetX,
98
+ lineHeight,
99
+ );
100
+ context.fillStyle = this.color(severity);
101
+ context.fill();
102
+
103
+ // Restore full opacity, otherwise every layer drawn after this one --
104
+ // including the text of every subsequent line -- inherits the fade.
105
+ context.globalAlpha = 1;
106
+
107
+ // Draw diagnostic range rectangle in the foreground
108
+ // TODO: We need to update the state to have specific ranges
109
+ // context.globalAlpha = 1;
110
+ // context.beginPath();
111
+ // context.rect(offsetX, offsetY, textWidth, lineHeight);
112
+ // context.fillStyle = this.color(severity);
113
+ // context.fill();
114
+ }
115
+
116
+ /**
117
+ * Given a position and a set of line ranges, return
118
+ * the line number the position falls within
119
+ */
120
+ private findLine(pos: number, lines: Lines) {
121
+ const index = lines.findIndex((spans) => {
122
+ const start = spans.slice(0, 1)[0];
123
+ const end = spans.slice(-1)[0];
124
+
125
+ if (!start || !end) {
126
+ return false;
127
+ }
128
+
129
+ return start.from <= pos && pos <= end.to;
130
+ });
131
+
132
+ // Line numbers begin at 1
133
+ return index + 1;
134
+ }
135
+
136
+ /**
137
+ * Colors from @codemirror/lint
138
+ * https://github.com/codemirror/lint/blob/e0671b43c02e72766ad1afe1579b7032fdcdb6c1/src/lint.ts#L597
139
+ */
140
+ private color(severity: Severity) {
141
+ return severity === "error"
142
+ ? "#d11"
143
+ : severity === "warning"
144
+ ? "orange"
145
+ : "#999";
146
+ }
147
+
148
+ /** Sorts severity from most to least severe */
149
+ private sort(a: Severity, b: Severity) {
150
+ return this.score(b) - this.score(a);
151
+ }
152
+
153
+ /** Assigns a score to severity, with most severe being the highest */
154
+ private score(s: Severity) {
155
+ switch (s) {
156
+ case "error": {
157
+ return 3;
158
+ }
159
+ case "warning": {
160
+ return 2;
161
+ }
162
+ default: {
163
+ return 1;
164
+ }
165
+ }
166
+ }
167
+ }
168
+
169
+ export function diagnostics(view: EditorView): DiagnosticState {
170
+ return new DiagnosticState(view);
171
+ }