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