jevstrudel: a minimap beside the code, with the playing lines on it. The code is drawn as blocks in the theme's own colours, the lines playing now in the tints the editor gives them (lineHighlights.mjs), and the part of the song in view as a box; click or drag it to scroll.

Prior art: @replit/codemirror-minimap, whose mounting (a sticky gutter inside the scroller, with a right scroll margin so no code hides under it where lines fit), colour lookup (highlightTree with highlightingFor, each class's colour read off a hidden token) and scroll mapping this follows. It is not used because it draws only the text, selections and diagnostics, and the playing lines are the point here. Unlike it, the text is drawn once per change to the code or theme into an offscreen canvas: the highlights change every animation frame, and each frame redraws only 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';

The code at this fraction of the editor's size, at most MAX_WIDTH wide, and never more than a sixth of the editor.

22const RATIO = 6;
23const MAX_WIDTH = 110;

Canvas pixels per CSS pixel, for sharp blocks on any screen.

25const dpr = () => Math.max(1, Math.round(globalThis.devicePixelRatio || 1));
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  }

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  }

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  }

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  }

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  }

Where the minimap's view of the code starts, in minimap pixels: the 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  }
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);

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);

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  }

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  }
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});

The editor's isMinimapEnabled setting (codemirror.mjs).

259export const minimap = () => [theme, plugin];