@batchfy/codemirror-minimap 0.1.0 → 0.1.1

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,402 @@
1
+ import { BlockInfo, EditorView } from "@codemirror/view";
2
+ import { Scale } from "./Config.js";
3
+ import { LinesState, type Lines } from "./LinesState.js";
4
+
5
+ /** Editor pixels that fit into a single minimap pixel. */
6
+ export const SCALE = Scale.PixelMultiplier * Scale.SizeRatio;
7
+
8
+ const clamp = (value: number, min: number, max: number) =>
9
+ Math.min(Math.max(value, min), max);
10
+
11
+ export type LayoutInput = {
12
+ /** Rows the minimap draws: one per line it lays out, folds collapsed. */
13
+ rowCount: number;
14
+ /** Height of one row. */
15
+ lineHeight: number;
16
+ paddingTop: number;
17
+ paddingBottom: number;
18
+ /** Height of the visible minimap. */
19
+ height: number;
20
+ /** Rows the editor currently shows on screen, inclusive. */
21
+ firstVisibleRow: number;
22
+ lastVisibleRow: number;
23
+ /**
24
+ * Rows a screenful holds when none of its lines wrap.
25
+ *
26
+ * How many rows are on screen right now swings with how much the lines on
27
+ * it wrap: a screen of code holds many lines, a screen of prose few. This
28
+ * count follows from the editor's height and its line height alone, so it
29
+ * holds still while the document scrolls.
30
+ */
31
+ rowsPerScreen: number;
32
+ };
33
+
34
+ export type Layout = {
35
+ /** Height of every row plus the document padding. */
36
+ contentHeight: number;
37
+ /** How far the rows are scrolled up inside the visible minimap. */
38
+ scrollTop: number;
39
+ /** Position of the viewport overlay inside the visible minimap. */
40
+ overlayTop: number;
41
+ overlayHeight: number;
42
+ /** Rows the minimap has to draw, carried through for the inverse. */
43
+ rowCount: number;
44
+ /**
45
+ * Rows the document can scroll through if none of its lines wrap: the ones
46
+ * past the first screenful.
47
+ *
48
+ * Wrapping puts fewer document lines on a screen than there are rows in it,
49
+ * so the editor can carry on past this — `rowAtOverlayTop` covers the rest.
50
+ */
51
+ scrollableRows: number;
52
+ /**
53
+ * How far the overlay's top edge moves between the first row of the
54
+ * document and the last, and so how far a drag has to travel to cross the
55
+ * document. It is `overlayTravel / scrollableRows` per row, which is what
56
+ * `rowAtOverlayTop` inverts.
57
+ */
58
+ overlayTravel: number;
59
+ };
60
+
61
+ /**
62
+ * Places the rows and the viewport overlay together.
63
+ *
64
+ * The overlay marks lines, not pixels, so its position cannot be derived from
65
+ * the editor's scroll offset: a wrapped line takes several rows of the editor
66
+ * and one row of the minimap, so equal scroll fractions stop meaning equal
67
+ * positions as soon as wrapping, folding or line-height variation enters the
68
+ * document. Both halves are therefore anchored on the rows that are actually on
69
+ * screen, which keeps the overlay on top of the content it stands for.
70
+ *
71
+ * The rows scroll in step with progress through the document, so the overlay
72
+ * ends up where that progress says it should: at the top on the first row, at
73
+ * the bottom on the last. Near either end, and whenever the rows have less to
74
+ * give than the progress asks for, the rows stop and the overlay takes over the
75
+ * remaining travel — covering its own rows always wins over sitting where the
76
+ * scroll fraction would like it to.
77
+ */
78
+ export function computeLayout(input: LayoutInput): Layout {
79
+ const { rowCount, lineHeight, paddingTop, paddingBottom, height } = input;
80
+
81
+ const lastRow = Math.max(0, rowCount - 1);
82
+ const first = clamp(input.firstVisibleRow, 0, lastRow);
83
+ const last = clamp(input.lastVisibleRow, first, lastRow);
84
+ const visibleRows = last - first + 1;
85
+
86
+ const contentHeight = paddingTop + paddingBottom + rowCount * lineHeight;
87
+ const overlayHeight = Math.min(height, visibleRows * lineHeight);
88
+ const rowTop = paddingTop + first * lineHeight;
89
+
90
+ /**
91
+ * Measured against a screenful of unwrapped lines rather than against the
92
+ * rows that happen to be on screen. A denominator that moved with the
93
+ * current wrapping would shrink as a screen of code scrolled into a screen
94
+ * of wrapped prose, and the rows would slide backwards underneath an
95
+ * overlay that had just moved forwards.
96
+ */
97
+ const scrollableRows = Math.max(0, rowCount - input.rowsPerScreen);
98
+ const progress =
99
+ scrollableRows <= 0 ? 0 : clamp(first / scrollableRows, 0, 1);
100
+
101
+ const maxScroll = Math.max(0, contentHeight - height);
102
+ const scrollTop = clamp(
103
+ maxScroll * progress,
104
+ Math.max(0, rowTop - (height - overlayHeight)),
105
+ Math.min(maxScroll, rowTop),
106
+ );
107
+
108
+ /**
109
+ * Measured against a screenful of rows rather than against the overlay's
110
+ * current height, for the same reason `progress` is: an overlay shrunk by
111
+ * the wrapping on screen right now would report a travel that the rows it
112
+ * has to move over cannot back up, and a drag would run ahead of them.
113
+ */
114
+ const overlayTravel = Math.max(
115
+ 0,
116
+ Math.min(height, contentHeight) -
117
+ paddingTop -
118
+ paddingBottom -
119
+ input.rowsPerScreen * lineHeight,
120
+ );
121
+
122
+ return {
123
+ contentHeight,
124
+ scrollTop,
125
+ overlayTop: rowTop - scrollTop,
126
+ overlayHeight,
127
+ rowCount,
128
+ scrollableRows,
129
+ overlayTravel,
130
+ };
131
+ }
132
+
133
+ export type Geometry = Layout & {
134
+ lineHeight: number;
135
+ paddingTop: number;
136
+ paddingBottom: number;
137
+ /** Height of the visible minimap. */
138
+ height: number;
139
+ };
140
+
141
+ /**
142
+ * The part of the geometry that scrolling does not change.
143
+ *
144
+ * Everything here follows from the document's length, the theme's row height
145
+ * and the size of the editor, so it holds still while the document moves under
146
+ * the overlay. A drag can therefore measure it once at the outset instead of on
147
+ * every report from the pointer — and the type is the argument that this is
148
+ * safe, since a function given only these values cannot read the scroll
149
+ * position it would be caching past.
150
+ */
151
+ export type StableGeometry = Pick<
152
+ Geometry,
153
+ | "paddingTop"
154
+ | "lineHeight"
155
+ | "rowCount"
156
+ | "scrollableRows"
157
+ | "overlayTravel"
158
+ >;
159
+
160
+ /**
161
+ * The row height the renderer measured, in minimap pixels.
162
+ *
163
+ * It comes from the computed style of a mock line, which only the renderer is
164
+ * set up to measure, and it changes with the theme rather than with the scroll
165
+ * position. Sharing the measurement lets the overlay lay itself out against the
166
+ * exact grid the rows were drawn on instead of a second estimate of it.
167
+ */
168
+ const rowHeights = new WeakMap<EditorView, number>();
169
+
170
+ export function setRowHeight(view: EditorView, canvasLineHeight: number) {
171
+ const lineHeight = canvasLineHeight / Scale.PixelMultiplier;
172
+
173
+ if (rowHeights.get(view) !== lineHeight) {
174
+ rowHeights.set(view, lineHeight);
175
+ measured.delete(view);
176
+ }
177
+ }
178
+
179
+ /** Index of the row the minimap draws the given document position on. */
180
+ export function rowAtPos(lines: Lines, pos: number): number {
181
+ let low = 0;
182
+ let high = lines.length - 1;
183
+
184
+ while (low < high) {
185
+ const mid = Math.ceil((low + high) / 2);
186
+ const line = lines[mid];
187
+
188
+ if (line && line.length && line[0].from <= pos) {
189
+ low = mid;
190
+ } else {
191
+ high = mid - 1;
192
+ }
193
+ }
194
+
195
+ return Math.max(0, low);
196
+ }
197
+
198
+ /**
199
+ * The line block at a vertical position, found by bisecting the document.
200
+ *
201
+ * `view.lineBlockAtHeight` answers this directly, but it refuses to run while
202
+ * the editor is mid-update, which is when the minimap draws. Blocks are ordered
203
+ * by height, so the same answer is reachable through `view.lineBlockAt`, which
204
+ * carries no such restriction.
205
+ */
206
+ function lineBlockAtHeight(view: EditorView, height: number): BlockInfo {
207
+ const { doc } = view.state;
208
+ let low = 1;
209
+ let high = doc.lines;
210
+
211
+ while (low < high) {
212
+ const mid = Math.floor((low + high) / 2);
213
+
214
+ if (view.lineBlockAt(doc.line(mid).from).bottom <= height) {
215
+ low = mid + 1;
216
+ } else {
217
+ high = mid;
218
+ }
219
+ }
220
+
221
+ return view.lineBlockAt(doc.line(low).from);
222
+ }
223
+
224
+ /** The rows the editor currently shows on screen, inclusive. */
225
+ function visibleRows(view: EditorView): { first: number; last: number } {
226
+ const lines = view.state.field(LinesState);
227
+ const { top, bottom } = view.scrollDOM.getBoundingClientRect();
228
+ const documentTop = view.documentTop;
229
+
230
+ return {
231
+ first: rowAtPos(lines, lineBlockAtHeight(view, top - documentTop).from),
232
+ last: rowAtPos(lines, lineBlockAtHeight(view, bottom - documentTop).to),
233
+ };
234
+ }
235
+
236
+ /**
237
+ * The last geometry measured for a view, and what it was measured for.
238
+ *
239
+ * The overlay and the renderer both need the geometry and both run on every
240
+ * update, so measuring it twice means paying twice for the three
241
+ * `getBoundingClientRect` calls behind `view.dom`, `view.scrollDOM` and
242
+ * `view.documentTop`, and for the two bisections of the height map. Handing
243
+ * both of them the same token — the `ViewUpdate` or the scroll event they are
244
+ * responding to — lets the second one reuse the first one's answer.
245
+ */
246
+ const measured = new WeakMap<
247
+ EditorView,
248
+ { token: object; geometry: Geometry }
249
+ >();
250
+
251
+ /**
252
+ * Where the minimap draws its rows and its overlay for the current state.
253
+ *
254
+ * `token` stands for the moment being drawn: anything measured for the same
255
+ * token describes the same moment, so it is measured once. Callers with no
256
+ * moment to name pass nothing and get a fresh measurement.
257
+ */
258
+ export function readGeometry(view: EditorView, token?: object): Geometry {
259
+ if (token) {
260
+ const previous = measured.get(view);
261
+
262
+ if (previous && previous.token === token) {
263
+ return previous.geometry;
264
+ }
265
+ }
266
+
267
+ const geometry = measureGeometry(view);
268
+
269
+ if (token) {
270
+ measured.set(view, { token, geometry });
271
+ }
272
+
273
+ return geometry;
274
+ }
275
+
276
+ function measureGeometry(view: EditorView): Geometry {
277
+ const lineHeight =
278
+ rowHeights.get(view) || view.defaultLineHeight / SCALE || 0;
279
+ const paddingTop = view.documentPadding.top / SCALE;
280
+ const paddingBottom = view.documentPadding.bottom / SCALE;
281
+ const height = view.dom.getBoundingClientRect().height;
282
+ const { first, last } = visibleRows(view);
283
+
284
+ const layout = computeLayout({
285
+ rowCount: view.state.field(LinesState).length,
286
+ lineHeight,
287
+ paddingTop,
288
+ paddingBottom,
289
+ height,
290
+ firstVisibleRow: first,
291
+ lastVisibleRow: last,
292
+ rowsPerScreen: view.defaultLineHeight
293
+ ? view.scrollDOM.clientHeight / view.defaultLineHeight
294
+ : 0,
295
+ });
296
+
297
+ return { ...layout, lineHeight, paddingTop, paddingBottom, height };
298
+ }
299
+
300
+ /** The row drawn `y` pixels below the top of the visible minimap. */
301
+ export function rowAtY(geometry: Geometry, y: number): number {
302
+ if (geometry.lineHeight <= 0) {
303
+ return 0;
304
+ }
305
+
306
+ const offset = geometry.scrollTop + y - geometry.paddingTop;
307
+ return Math.floor(offset / geometry.lineHeight);
308
+ }
309
+
310
+ /**
311
+ * The row the editor would have to put at the top of its viewport for the
312
+ * overlay to sit at `overlayTop`.
313
+ *
314
+ * This is the inverse of the placement `computeLayout` performs, so dragging
315
+ * the overlay to a position and letting the layout put it back lands on the
316
+ * same pixel. It cannot be read off `rowAtY`: the rows underneath the overlay
317
+ * scroll as the overlay moves, so the row drawn at a position is not the row
318
+ * that would be on screen were the overlay taken there.
319
+ *
320
+ * The answer carries its fraction. A whole number of rows is not enough to
321
+ * reach the end of a document: the editor stops with the last line against the
322
+ * foot of the viewport, which in general falls part-way through a row, and a
323
+ * drag that could only name whole rows would stop short of it by that part and
324
+ * need a jump to finish.
325
+ */
326
+ export function rowAtOverlayTop(
327
+ geometry: StableGeometry,
328
+ overlayTop: number,
329
+ ): number {
330
+ if (
331
+ geometry.overlayTravel <= 0 ||
332
+ geometry.scrollableRows <= 0 ||
333
+ geometry.lineHeight <= 0
334
+ ) {
335
+ return 0;
336
+ }
337
+
338
+ const offset = overlayTop - geometry.paddingTop;
339
+
340
+ /**
341
+ * Past its travel the overlay is still moving, and the drag has to follow
342
+ * it there.
343
+ *
344
+ * `scrollableRows` counts rows against a screenful of unwrapped lines, but
345
+ * a screen of wrapped lines holds fewer document lines than it has rows,
346
+ * so the editor can put rows past that one at the top of its viewport. The
347
+ * layout already draws them: `progress` stops at 1, the rows stop
348
+ * scrolling, and the overlay covers the remaining distance on its own, one
349
+ * row height per row. Ending the inverse at `scrollableRows` instead left a
350
+ * drag stuck a screenful's worth of wrapping short of the end, reachable
351
+ * only by scrolling the editor itself.
352
+ */
353
+ if (offset >= geometry.overlayTravel) {
354
+ return clamp(
355
+ geometry.scrollableRows +
356
+ (offset - geometry.overlayTravel) / geometry.lineHeight,
357
+ 0,
358
+ Math.max(0, geometry.rowCount - 1),
359
+ );
360
+ }
361
+
362
+ const progress = clamp(offset / geometry.overlayTravel, 0, 1);
363
+
364
+ return progress * geometry.scrollableRows;
365
+ }
366
+
367
+ /**
368
+ * Scrolls the editor until `row` sits `offset` pixels down the viewport.
369
+ *
370
+ * `row` may name a point inside a row rather than the start of one, and the
371
+ * fraction is taken against that row's own height so that it means the same
372
+ * part of a wrapped line as of an unwrapped one. Scrolling to whole rows is
373
+ * enough for a click, which is aiming at a line; a drag needs the fraction, or
374
+ * it cannot follow the pointer through the last part of a row and stops short
375
+ * of the end of the document.
376
+ */
377
+ export function scrollToRow(view: EditorView, row: number, offset = 0) {
378
+ const lines = view.state.field(LinesState);
379
+ const wanted = clamp(row, 0, lines.length - 1);
380
+ const index = Math.floor(wanted);
381
+ const line = lines[index];
382
+
383
+ if (!line || !line.length) {
384
+ return;
385
+ }
386
+
387
+ const block = view.lineBlockAt(line[0].from);
388
+ const withinRow = (wanted - index) * block.height;
389
+
390
+ /**
391
+ * Set outright rather than nudged by a difference from where the editor is
392
+ * now. `lineBlockAt` measures from the top of the document, and the
393
+ * document begins `paddingTop` into the scroller's content, so that sum is
394
+ * already the position that puts the row against the top of the viewport.
395
+ * Working it out as a difference meant reading the current scroll offset,
396
+ * the scroller's box and the document's box — three measurements of the
397
+ * present, to arrive somewhere that never depended on it. The scroller
398
+ * clamps the result at both ends by itself.
399
+ */
400
+ view.scrollDOM.scrollTop =
401
+ view.documentPadding.top + block.top + withinRow - offset;
402
+ }
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ import { DiagnosticState, diagnostics } from "./diagnostics.js";
6
6
  import { SelectionState, selections } from "./selections.js";
7
7
  import { TextState, text } from "./text.js";
8
8
  import { LinesState } from "./LinesState.js";
9
+ import { readGeometry, setRowHeight } from "./geometry.js";
9
10
  import crelt from "crelt";
10
11
  import { GUTTER_WIDTH, drawLineGutter } from "./Gutters.js";
11
12
 
@@ -144,7 +145,7 @@ const minimapClass = ViewPlugin.fromClass(
144
145
  this.text.update(update);
145
146
  this.selection.update(update);
146
147
  this.diagnostic.update(update);
147
- this.render();
148
+ this.render(update);
148
149
  }
149
150
  }
150
151
 
@@ -161,7 +162,12 @@ const minimapClass = ViewPlugin.fromClass(
161
162
  return maxWidth;
162
163
  }
163
164
 
164
- render() {
165
+ /**
166
+ * `token` names the moment being drawn, and is passed on to
167
+ * `readGeometry` so that the overlay, which draws the same moment,
168
+ * measures it only once between them.
169
+ */
170
+ render(token?: object) {
165
171
  // If we don't have elements to draw to exit early
166
172
  if (!this.dom || !this.canvas || !this.inner) {
167
173
  return;
@@ -215,10 +221,12 @@ const minimapClass = ViewPlugin.fromClass(
215
221
 
216
222
  /* We need to get the correct font dimensions before this to measure characters */
217
223
  const { charWidth, lineHeight } = this.text.measure(context);
224
+ setRowHeight(this.view, lineHeight);
218
225
 
219
226
  let { startIndex, endIndex, offsetY } = this.canvasStartAndEndIndex(
220
227
  context,
221
228
  lineHeight,
229
+ token,
222
230
  );
223
231
 
224
232
  const gutters = this.view.state.facet(Config).gutters;
@@ -261,33 +269,39 @@ const minimapClass = ViewPlugin.fromClass(
261
269
  private canvasStartAndEndIndex(
262
270
  context: CanvasRenderingContext2D,
263
271
  lineHeight: number,
272
+ token?: object,
264
273
  ) {
265
- const { top: rawTop, bottom: rawBottom } =
266
- this.view.documentPadding;
267
- const pTop = rawTop / Scale.SizeRatio;
268
- const pBottom = rawBottom / Scale.SizeRatio;
269
-
274
+ const pTop = this.view.documentPadding.top / Scale.SizeRatio;
270
275
  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
276
 
278
- const lineCount = this.view.state.field(LinesState).length;
279
- const totalHeight = pTop + pBottom + lineCount * lineHeight;
277
+ /**
278
+ * The rows are scrolled to wherever the overlay needs them: the two
279
+ * are laid out together so that the overlay covers the rows the
280
+ * editor is showing rather than an approximation of them.
281
+ */
282
+ const canvasTop =
283
+ readGeometry(this.view, token).scrollTop *
284
+ Scale.PixelMultiplier;
280
285
 
281
- const canvasTop = Math.max(
282
- 0,
283
- scrollPercent * (totalHeight - canvasHeight),
284
- );
285
- const offsetY = Math.max(0, pTop - canvasTop);
286
+ if (lineHeight <= 0) {
287
+ return { startIndex: 0, endIndex: 0, offsetY: 0 };
288
+ }
286
289
 
287
- const startIndex = Math.round(
288
- Math.max(0, canvasTop - pTop) / lineHeight,
290
+ /**
291
+ * The first row to draw is the one the top edge falls inside, not
292
+ * the nearest one to it: rounding would leave every row up to half
293
+ * a row away from where the overlay expects it, and by a different
294
+ * amount at every scroll position. Whatever part of that row lies
295
+ * above the edge goes into `offsetY`, which is why it may be
296
+ * negative — the row is drawn from off the top of the canvas so the
297
+ * rest of it lands exactly where the overlay marks it.
298
+ */
299
+ const startIndex = Math.max(
300
+ 0,
301
+ Math.floor((canvasTop - pTop) / lineHeight),
289
302
  );
290
- const spaceForLines = Math.round(
303
+ const offsetY = pTop + startIndex * lineHeight - canvasTop;
304
+ const spaceForLines = Math.ceil(
291
305
  (canvasHeight - offsetY) / lineHeight,
292
306
  );
293
307
 
@@ -320,8 +334,8 @@ const minimapClass = ViewPlugin.fromClass(
320
334
  },
321
335
  {
322
336
  eventHandlers: {
323
- scroll() {
324
- requestAnimationFrame(() => this.render());
337
+ scroll(event) {
338
+ requestAnimationFrame(() => this.render(event));
325
339
  },
326
340
  },
327
341
  provide: (plugin) => {