@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.
package/src/Overlay.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view";
2
- import { Config, Scale } from "./Config.js";
2
+ import { Config } from "./Config.js";
3
+ import {
4
+ readGeometry,
5
+ rowAtOverlayTop,
6
+ rowAtY,
7
+ scrollToRow,
8
+ type StableGeometry,
9
+ } from "./geometry.js";
3
10
  import crelt from "crelt";
4
11
 
5
12
  const Theme = EditorView.theme({
@@ -8,6 +15,10 @@ const Theme = EditorView.theme({
8
15
  top: 0,
9
16
  height: "100%",
10
17
  width: "100%",
18
+ // The minimap is a sibling of `.cm-content` inside the scroller, so
19
+ // without this it inherits the caret the editor shows over text. It is
20
+ // not text: clicking it jumps, and the overlay is dragged.
21
+ cursor: "default",
11
22
  "&.cm-minimap-overlay-mouse-over": {
12
23
  opacity: 0,
13
24
  transition: "visibility 0s linear 300ms, opacity 300ms",
@@ -27,6 +38,7 @@ const Theme = EditorView.theme({
27
38
  top: 0,
28
39
  width: "100%",
29
40
  transition: "top 0s ease-in 0ms",
41
+ cursor: "grab",
30
42
  "&:hover": {
31
43
  opacity: "0.3",
32
44
  },
@@ -37,20 +49,41 @@ const Theme = EditorView.theme({
37
49
  transition: "visibility 0s linear 0ms, opacity 300ms",
38
50
  "& .cm-minimap-overlay": {
39
51
  opacity: "0.4",
52
+ cursor: "grabbing",
40
53
  },
41
54
  },
42
55
  },
43
56
  });
44
57
 
45
- const SCALE = Scale.PixelMultiplier * Scale.SizeRatio;
46
-
47
58
  const OverlayView = ViewPlugin.fromClass(
48
59
  class {
49
60
  private container: HTMLElement | undefined;
50
61
  private dom: HTMLElement | undefined;
51
62
 
52
63
  private _isDragging: boolean = false;
53
- private _dragStartY: number | undefined;
64
+ /**
65
+ * Where inside the overlay the drag took hold of it, as a fraction of
66
+ * its height rather than a count of pixels down from its top.
67
+ *
68
+ * The overlay is sized to the lines on screen, so it shrinks as a drag
69
+ * carries it from a screen of code into one of wrapped prose. A pixel
70
+ * offset taken when it was tall goes on pointing at where its middle
71
+ * used to be, which by the end of the document is somewhere below its
72
+ * bottom edge — and the pointer would have to leave the minimap, often
73
+ * the window, to put the overlay where it belongs. A fraction shrinks
74
+ * with it and stays under the thumb.
75
+ */
76
+ private _dragAnchor: number | undefined;
77
+ /** The last position the pointer reported, waiting for a frame. */
78
+ private _dragPointerY: number | undefined;
79
+ private _dragFrame: number | undefined;
80
+ /**
81
+ * Measured when the drag starts and kept for its duration. None of it
82
+ * moves as the document scrolls, and `update` drops it so that anything
83
+ * which does move it — an edit, a resize, a change of theme — is
84
+ * measured again.
85
+ */
86
+ private _dragGeometry: StableGeometry | undefined;
54
87
 
55
88
  // Bound once so that removeEventListener sees the same reference that
56
89
  // addEventListener was given; `.bind` at call time yields a new
@@ -85,11 +118,16 @@ const OverlayView = ViewPlugin.fromClass(
85
118
 
86
119
  // Initially set overlay configuration styles, height, top
87
120
  this.computeShowOverlay();
88
- this.computeHeight();
89
- this.computeTop();
121
+ this.position();
90
122
  }
91
123
 
92
124
  private remove() {
125
+ // A minimap turned off or torn down mid-drag would otherwise leave
126
+ // the whole page holding the dragging cursor.
127
+ if (this._isDragging) {
128
+ this.endDrag();
129
+ }
130
+
93
131
  if (this.container) {
94
132
  this.container.removeEventListener(
95
133
  "mousedown",
@@ -115,41 +153,38 @@ const OverlayView = ViewPlugin.fromClass(
115
153
  }
116
154
 
117
155
  if (now) {
156
+ // Anything that reaches an update may have changed the size of
157
+ // the editor, the length of the document or the height of a
158
+ // row, so a drag in progress measures them again.
159
+ this._dragGeometry = undefined;
160
+
118
161
  this.computeShowOverlay();
119
162
 
120
- if (update.geometryChanged) {
121
- this.computeHeight();
122
- this.computeTop();
123
- }
163
+ // Unconditionally, and not just on `geometryChanged`: the rows
164
+ // are redrawn on every update, and an overlay that skipped one
165
+ // would be left marking rows that have since moved under it.
166
+ // The renderer runs first and measures the same update, so this
167
+ // reads that measurement rather than taking its own.
168
+ this.position(update);
124
169
  }
125
170
  }
126
171
 
127
- public computeHeight() {
172
+ /**
173
+ * Covers the rows the editor is showing. The renderer scrolls its rows
174
+ * to the position the same geometry prescribes, so the overlay lands on
175
+ * the drawn content whatever the document does with wrapping or folds.
176
+ */
177
+ public position(token?: object) {
128
178
  if (!this.dom) {
129
179
  return;
130
180
  }
131
181
 
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
- }
182
+ const { overlayTop, overlayHeight } = readGeometry(
183
+ this.view,
184
+ token,
185
+ );
186
+ this.dom.style.top = overlayTop + "px";
187
+ this.dom.style.height = overlayHeight + "px";
153
188
  }
154
189
 
155
190
  public computeShowOverlay() {
@@ -185,123 +220,121 @@ const OverlayView = ViewPlugin.fromClass(
185
220
  return;
186
221
  }
187
222
 
223
+ /**
224
+ * The minimap sits inside the scroller alongside `.cm-content`, so
225
+ * a mousedown on it starts a native text selection that runs on for
226
+ * as long as the button is held — which is what leaves the pointer
227
+ * showing a caret through the whole drag, and what puts a selection
228
+ * in the document that nobody asked for. Neither gesture here has
229
+ * anything to do with selecting: one jumps, one drags.
230
+ */
231
+ event.preventDefault();
232
+
188
233
  // If target is the overlay start dragging
189
234
  const { clientY, target } = event;
190
- if (target === this.dom) {
191
- this._dragStartY = event.clientY;
235
+ if (target === this.dom && this.dom) {
236
+ const grabbed = this.dom.getBoundingClientRect();
237
+
238
+ this._dragAnchor =
239
+ grabbed.height > 0
240
+ ? (clientY - grabbed.top) / grabbed.height
241
+ : 0;
192
242
  this._isDragging = true;
193
243
  this.container.classList.add("cm-minimap-overlay-active");
244
+
245
+ // The pointer spends most of a drag off the overlay, and some
246
+ // of it off the minimap altogether, where the rule on the
247
+ // overlay no longer reaches it.
248
+ document.body.style.cursor = "grabbing";
194
249
  return;
195
250
  }
196
251
 
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;
252
+ // A click anywhere else on the minimap centres the row underneath it
253
+ const geometry = readGeometry(this.view);
254
+ const y = clientY - this.container.getBoundingClientRect().top;
208
255
 
209
- const top = Math.max(0, scrollTop - visibleTop);
210
- this.view.scrollDOM.scrollTop = top + deltaY - clientHeight / 2;
256
+ scrollToRow(
257
+ this.view,
258
+ rowAtY(geometry, y),
259
+ this.view.scrollDOM.clientHeight / 2,
260
+ );
211
261
  }
212
262
 
213
263
  private onMouseUp(_event: MouseEvent) {
214
264
  // 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");
265
+ if (this._isDragging) {
266
+ this.endDrag();
219
267
  }
220
268
  }
221
269
 
222
- private onMouseMove(event: MouseEvent) {
223
- if (!this._isDragging || !this.dom) {
224
- return;
270
+ private endDrag() {
271
+ if (this._dragFrame !== undefined) {
272
+ cancelAnimationFrame(this._dragFrame);
273
+ this._dragFrame = undefined;
225
274
  }
226
275
 
227
- event.preventDefault();
228
- event.stopPropagation();
276
+ this._dragAnchor = undefined;
277
+ this._dragPointerY = undefined;
278
+ this._dragGeometry = undefined;
279
+ this._isDragging = false;
280
+ this.container?.classList.remove("cm-minimap-overlay-active");
281
+ document.body.style.removeProperty("cursor");
282
+ }
229
283
 
230
- // Without an existing position, we're just beginning to drag.
231
- if (!this._dragStartY) {
232
- this._dragStartY = event.clientY;
284
+ private onMouseMove(event: MouseEvent) {
285
+ if (!this._isDragging || this._dragAnchor === undefined) {
233
286
  return;
234
287
  }
235
288
 
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;
289
+ event.preventDefault();
290
+ event.stopPropagation();
251
291
 
252
- const atTop = scrollPosition === 0;
253
- const atBottom =
254
- Math.round(scrollPosition) >=
255
- Math.round(contentHeight - editorHeight);
292
+ this._dragPointerY = event.clientY;
256
293
 
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;
294
+ /**
295
+ * At most one move per frame. A trackpad reports positions faster
296
+ * than the screen can show them, and acting on each one means
297
+ * reading the layout back immediately after the last one wrote to
298
+ * it — the reading is what costs, and none of the extra readings
299
+ * can reach the screen anyway.
300
+ */
301
+ if (this._dragFrame === undefined) {
302
+ this._dragFrame = requestAnimationFrame(() => {
303
+ this._dragFrame = undefined;
304
+ this.dragToPointer();
305
+ });
266
306
  }
307
+ }
308
+
309
+ private dragToPointer() {
267
310
  if (
268
- (atBottom && movingDown) ||
269
- (atBottom && event.clientY > canvasAbsBot)
311
+ !this._isDragging ||
312
+ this._dragAnchor === undefined ||
313
+ this._dragPointerY === undefined ||
314
+ !this.container ||
315
+ !this.dom
270
316
  ) {
271
317
  return;
272
318
  }
273
319
 
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;
320
+ this._dragGeometry ??= readGeometry(this.view);
282
321
 
283
322
  /**
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
323
+ * The overlay's drawn height, rather than a freshly computed one:
324
+ * the anchor was taken against what is on the screen, the two rows
325
+ * a frame of lag can cost are invisible at this scale, and reading
326
+ * it is a single box where computing it means bisecting the height
327
+ * map twice.
288
328
  */
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
- );
329
+ const overlayHeight = this.dom.getBoundingClientRect().height;
330
+ const containerTop = this.container.getBoundingClientRect().top;
298
331
 
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";
332
+ const top =
333
+ this._dragPointerY -
334
+ this._dragAnchor * overlayHeight -
335
+ containerTop;
336
+
337
+ scrollToRow(this.view, rowAtOverlayTop(this._dragGeometry, top));
305
338
  }
306
339
 
307
340
  public destroy() {
@@ -310,8 +343,8 @@ const OverlayView = ViewPlugin.fromClass(
310
343
  },
311
344
  {
312
345
  eventHandlers: {
313
- scroll() {
314
- requestAnimationFrame(() => this.computeTop());
346
+ scroll(event) {
347
+ requestAnimationFrame(() => this.position(event));
315
348
  },
316
349
  },
317
350
  },