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.
The code at this fraction of the editor's size, at most MAX_WIDTH wide, and never more than a sixth of the editor.
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.
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.
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];