1// jevstrudel: a minimap beside the code, with the playing lines on it. The
2// code is drawn as blocks in the theme's own colours, the lines playing
3// now in the tints the editor gives them (lineHighlights.mjs), and the
4// part of the song in view as a box; click or drag it to scroll.
5//
6// Prior art: @replit/codemirror-minimap, whose mounting (a sticky gutter
7// inside the scroller, with a right scroll margin so no code hides under
8// it where lines fit), colour lookup (highlightTree with highlightingFor, each class's
9// colour read off a hidden token) and scroll mapping this follows. It is
10// not used because it draws only the text, selections and diagnostics,
11// and the playing lines are the point here. Unlike it, the text is drawn
12// once per change to the code or theme into an offscreen canvas: the
13// highlights change every animation frame, and each frame redraws only
14// them and the box.
15import { highlightingFor, syntaxTree } from '@codemirror/language';
16import { EditorView, ViewPlugin } from '@codemirror/view';
17import { highlightTree } from '@lezer/highlight';
18import { playingLines, TINT } from './lineHighlights.mjs';
19
20// The code at this fraction of the editor's size, at most MAX_WIDTH wide,
21// and never more than a sixth of the editor.
22const RATIO = 6;
23const MAX_WIDTH = 110;
24// Canvas pixels per CSS pixel, for sharp blocks on any screen.
25const dpr = () => Math.max(1, Math.round(globalThis.devicePixelRatio || 1));
26
27const theme = EditorView.theme({
28  '.cm-minimap': {
29    position: 'sticky',
30    right: 0,
31    top: 0,
32    flexShrink: 0,
33    alignSelf: 'flex-start',
34    cursor: 'pointer',
35    borderLeft: '1px solid color-mix(in srgb, currentColor 12%, transparent)',
36    zIndex: 3,
37    // opaque, over any long line that runs under it (as Replit's does)
38    background: 'var(--background)',
39  },
40  '.cm-minimap canvas': { display: 'block' },
41});
42
43class Minimap {
44  constructor(view) {
45    this.view = view;
46    this.dom = document.createElement('div');
47    this.dom.className = 'cm-minimap';
48    this.dom.setAttribute('aria-hidden', 'true');
49    this.canvas = document.createElement('canvas');
50    this.dom.appendChild(this.canvas);
51    view.scrollDOM.insertBefore(this.dom, view.contentDOM.nextSibling);
52    this.text = document.createElement('canvas'); // the code, drawn once per change
53    this.colors = new Map(); // highlight class → CSS colour
54    this.cssColors = new Map(); // any CSS colour → one a canvas takes
55    this.themeKey = null;
56    this.dirty = true;
57    this.frame = null;
58    this.onScroll = () => this.schedule();
59    view.scrollDOM.addEventListener('scroll', this.onScroll);
60    this.resize = new ResizeObserver(() => this.schedule());
61    this.resize.observe(view.scrollDOM);
62    this.dragging = false;
63    this.onPointer = (e) => this.pointer(e);
64    this.onUp = () => (this.dragging = false);
65    this.canvas.addEventListener('pointerdown', (e) => {
66      this.dragging = true;
67      this.canvas.setPointerCapture?.(e.pointerId);
68      this.pointer(e);
69    });
70    this.canvas.addEventListener('pointermove', (e) => this.dragging && this.pointer(e));
71    this.canvas.addEventListener('pointerup', this.onUp);
72    this.schedule();
73  }
74
75  update(update) {
76    const key = this.view.dom.className;
77    if (update.docChanged || key !== this.themeKey || syntaxTree(update.state) !== syntaxTree(update.startState)) {
78      if (key !== this.themeKey) this.colors.clear();
79      this.themeKey = key;
80      this.dirty = true;
81    }
82    this.schedule();
83  }
84
85  schedule() {
86    if (this.frame === null) this.frame = requestAnimationFrame(() => ((this.frame = null), this.draw()));
87  }
88
89  width() {
90    return Math.max(40, Math.min(MAX_WIDTH, Math.floor(this.view.dom.clientWidth / 6)));
91  }
92
93  // Line height and character width on the minimap, in CSS pixels.
94  metrics() {
95    return {
96      lineHeight: Math.max(2, this.view.defaultLineHeight / RATIO),
97      charWidth: Math.max(0.6, this.view.defaultCharacterWidth / RATIO),
98    };
99  }
100
101  // A highlight class's colour, read off a hidden token as the editor draws it.
102  colorOf(classes) {
103    if (!this.colors.has(classes)) {
104      const token = document.createElement('span');
105      token.className = classes;
106      const line = document.createElement('div');
107      line.className = 'cm-line';
108      line.style.display = 'none';
109      line.appendChild(token);
110      this.view.contentDOM.appendChild(line);
111      this.colors.set(classes, getComputedStyle(token).color);
112      line.remove();
113    }
114    return this.colors.get(classes);
115  }
116
117  // Any CSS colour (a name, var(), color-mix) as a canvas can fill it.
118  canvasColor(color) {
119    if (!this.cssColors.has(color)) {
120      const probe = document.createElement('span');
121      probe.style.color = color;
122      probe.style.display = 'none';
123      this.dom.appendChild(probe);
124      this.cssColors.set(color, getComputedStyle(probe).color);
125      probe.remove();
126    }
127    return this.cssColors.get(color);
128  }
129
130  // The code as blocks, one row per line, into the offscreen canvas.
131  drawText(width) {
132    const { state } = this.view;
133    const { lineHeight, charWidth } = this.metrics();
134    const scale = dpr();
135    const lines = state.doc.lines;
136    this.text.width = width * scale;
137    this.text.height = Math.max(1, Math.ceil(lines * lineHeight * scale));
138    const ctx = this.text.getContext('2d');
139    ctx.scale(scale, scale);
140    ctx.globalAlpha = 0.65;
141    const base = getComputedStyle(this.view.contentDOM).color;
142    // each character's colour, from the syntax tree
143    const styles = new Array(state.doc.length);
144    highlightTree(
145      syntaxTree(state),
146      { style: (tags) => highlightingFor(state, tags) },
147      (from, to, classes) => {
148        const color = this.colorOf(classes);
149        for (let i = from; i < to; i++) styles[i] = color;
150      },
151    );
152    const block = Math.max(1, lineHeight - 1);
153    for (let n = 1; n <= lines; n++) {
154      const line = state.doc.line(n);
155      const y = (n - 1) * lineHeight;
156      let runStart = -1;
157      let runColor = null;
158      const flush = (end) => {
159        if (runStart < 0) return;
160        const x = runStart * charWidth;
161        if (x < width) {
162          ctx.fillStyle = runColor;
163          ctx.fillRect(x, y, Math.min((end - runStart) * charWidth, width - x), block);
164        }
165        runStart = -1;
166      };
167      for (let i = 0; i < line.length; i++) {
168        const ch = line.text.charCodeAt(i);
169        const color = styles[line.from + i] ?? base;
170        if (ch === 32 || ch === 9) flush(i);
171        else if (runStart < 0 || color !== runColor) {
172          flush(i);
173          runStart = i;
174          runColor = color;
175        }
176      }
177      flush(line.length);
178    }
179    this.dirty = false;
180  }
181
182  // Where the minimap's view of the code starts, in minimap pixels: the
183  // whole song when it fits, else in step with the editor's scroll.
184  top(total, height) {
185    const { scrollTop, scrollHeight, clientHeight } = this.view.scrollDOM;
186    const max = scrollHeight - clientHeight;
187    const share = max > 0 ? scrollTop / max : 0;
188    return total > height ? share * (total - height) : 0;
189  }
190
191  draw() {
192    const width = this.width();
193    const height = this.view.scrollDOM.clientHeight;
194    const scale = dpr();
195    if (this.dom.style.width !== `${width}px`) {
196      this.dom.style.width = `${width}px`;
197      this.dirty = true;
198    }
199    this.dom.style.height = `${height}px`;
200    this.canvas.style.width = `${width}px`;
201    this.canvas.style.height = `${height}px`;
202    if (this.canvas.width !== width * scale) this.canvas.width = width * scale;
203    if (this.canvas.height !== height * scale) this.canvas.height = height * scale;
204    if (this.dirty) this.drawText(width);
205
206    const { lineHeight } = this.metrics();
207    const total = this.view.state.doc.lines * lineHeight;
208    const top = this.top(total, height);
209    const ctx = this.canvas.getContext('2d');
210    ctx.setTransform(scale, 0, 0, scale, 0, 0);
211    ctx.clearRect(0, 0, width, height);
212
213    // the playing lines, in the editor's tints but stronger, under the code
214    for (const [n, { color }] of playingLines(this.view.state)) {
215      const y = (n - 1) * lineHeight - top;
216      if (y + lineHeight < 0 || y > height) continue;
217      ctx.globalAlpha = Math.min(1, TINT * 4);
218      ctx.fillStyle = this.canvasColor(color ?? 'currentColor');
219      ctx.fillRect(0, y, width, lineHeight);
220    }
221    ctx.globalAlpha = 1;
222    ctx.drawImage(this.text, 0, top * scale, width * scale, height * scale, 0, 0, width, height);
223
224    // the part in view
225    const { scrollTop, clientHeight } = this.view.scrollDOM;
226    const boxTop = scrollTop / RATIO - top;
227    const boxHeight = Math.max(4, clientHeight / RATIO);
228    ctx.globalAlpha = 0.1;
229    ctx.fillStyle = this.canvasColor('currentColor');
230    ctx.fillRect(0, boxTop, width, boxHeight);
231    ctx.globalAlpha = 1;
232  }
233
234  // Click or drag: centre the editor on that point of the song.
235  pointer(e) {
236    const { lineHeight } = this.metrics();
237    const height = this.view.scrollDOM.clientHeight;
238    const total = this.view.state.doc.lines * lineHeight;
239    const y = e.clientY - this.canvas.getBoundingClientRect().top + this.top(total, height);
240    const n = Math.min(this.view.state.doc.lines, Math.max(1, Math.floor(y / lineHeight) + 1));
241    const block = this.view.lineBlockAt(this.view.state.doc.line(n).from);
242    this.view.scrollDOM.scrollTop = block.top - this.view.scrollDOM.clientHeight / 2;
243    e.preventDefault();
244  }
245
246  destroy() {
247    if (this.frame !== null) cancelAnimationFrame(this.frame);
248    this.view.scrollDOM.removeEventListener('scroll', this.onScroll);
249    this.resize.disconnect();
250    this.dom.remove();
251  }
252}
253
254const plugin = ViewPlugin.fromClass(Minimap, {
255  provide: (p) => EditorView.scrollMargins.of((view) => ({ right: view.plugin(p)?.width() ?? 0 })),
256});
257
258// The editor's `isMinimapEnabled` setting (codemirror.mjs).
259export const minimap = () => [theme, plugin];