├── .gitignore ├── .npmignore ├── .npmrc ├── .replit ├── CHANGELOG.md ├── README.md ├── dev ├── index.html ├── index.ts ├── massive.ts ├── snippets.ts └── vite.config.ts ├── package.json ├── src ├── Config.ts ├── Gutters.ts ├── LinesState.ts ├── Overlay.ts ├── diagnostics.ts ├── index.ts ├── linebasedstate.ts ├── selections.ts ├── text.ts └── types.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | 4 | .config 5 | .cache 6 | .DS_Store 7 | .npm_cache 8 | .upm 9 | .vscode 10 | .yarn-cache 11 | bun.lockb 12 | 13 | npm-debug.log 14 | yarn-error.log -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /dev 2 | /src 3 | /node_modules 4 | 5 | .cache 6 | .config 7 | .github 8 | .replit 9 | .upm 10 | .vscode 11 | bun.lockb 12 | 13 | replit.nix -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=true 2 | resolve-peers-from-workspace-root=true -------------------------------------------------------------------------------- /.replit: -------------------------------------------------------------------------------- 1 | run = "bun run dev" 2 | entrypoint = "index.ts" 3 | modules = ["bun-1.0:v1-20230911-f253fb1"] 4 | 5 | hidden = [".config", "bun.lockb"] 6 | 7 | [nix] 8 | channel = "stable-22_11" 9 | 10 | [deployment] 11 | build = ["sh", "-c", "mkdir .build && bun build index.ts > .build/index.js"] 12 | run = ["bun", ".build/index.js"] 13 | deploymentTarget = "cloudrun" 14 | 15 | [[ports]] 16 | localPort = 5173 17 | externalPort = 80 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.5.1 (2023-10-25) 2 | 3 | ### Bug fixes 4 | 5 | Remove circular imports to fix build 6 | 7 | ## 0.5.0 (2023-10-25) 8 | 9 | ### Breaking changes 10 | 11 | The `minimap` function to register the main extension was removed from the library and replaced with the `showMinimap` facet. 12 | 13 | The `MinimapGutterDecoration` facet to register gutters in the minimap was removed from the library and replaced with an option within the `showMinimap` facet. 14 | 15 | ### Bug fixes 16 | 17 | Bump postcss (dependency of Vite) patch version to 8.4.31 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Minimap for Codemirror 6 2 | 3 |
4 | 5 | Run on Replit badge 6 | 7 | 8 | NPM version badge 9 | 10 |
11 |
12 |
13 | image 14 | image 15 |
16 |
17 | 18 | 19 | ## Installation 20 | 21 | ``` 22 | bun i @replit/codemirror-minimap 23 | ``` 24 | 25 | ## Usage 26 | 27 | ```typescript 28 | import { basicSetup, EditorView } from 'codemirror'; 29 | import { showMinimap } from "@replit/codemirror-minimap" 30 | 31 | let create = (v: EditorView) => { 32 | const dom = document.createElement('div'); 33 | return { dom } 34 | } 35 | 36 | let view = new EditorView({ 37 | doc: "", 38 | extensions: [ 39 | basicSetup, 40 | showMinimap.compute(['doc'], (state) => { 41 | return { 42 | create, 43 | /* optional */ 44 | displayText: 'blocks', 45 | showOverlay: 'always', 46 | gutters: [ { 1: '#00FF00', 2: '#00FF00' } ], 47 | } 48 | }), 49 | ], 50 | parent: document.querySelector('#editor'), 51 | }) 52 | ``` 53 | 54 | ## Configuration Options 55 | 56 | The minimap extension exposes a few configuration options: 57 | 58 | **`displayText`**: customize how the editor text is displayed: 59 | 60 | ```typescript 61 | /** 62 | * displayText?: "blocks" | "characters"; 63 | * Defaults to "characters" 64 | */ 65 | { 66 | displayText: 'blocks' 67 | } 68 | ``` 69 | 70 | **`eventHandlers`**: attach event handlers to the minimap container element 71 | 72 | ```typescript 73 | /** 74 | * eventHandlers?: {[event in keyof DOMEventMap]?: EventHandler} 75 | */ 76 | { 77 | eventHandlers: { 78 | 'contextmenu': (e) => onContextMenu(e) 79 | } 80 | } 81 | ``` 82 | 83 | **`showOverlay`**: customize when the overlay showing the current viewport is visible 84 | 85 | ```typescript 86 | /** 87 | * showOverlay?: "always" | "mouse-over"; 88 | * Defaults to "always" 89 | */ 90 | { 91 | showOverlay: 'mouse-over' 92 | } 93 | ``` 94 | 95 | **`gutters`**: display a gutter on the left side of the minimap at specific lines 96 | 97 | ```typescript 98 | /** 99 | * gutters?: Array> 100 | * Where `number` is line number, and `string` is a color 101 | */ 102 | { 103 | gutters: [ { 1: '#00FF00', 2: 'green', 3: 'rgb(0, 100, 50)' } ] 104 | } 105 | ``` 106 | 107 | ## Build and Publish 108 | 109 | To build from source: 110 | 111 | ``` 112 | bun build 113 | ``` 114 | 115 | To publish a new version to NPM registry: 116 | 117 | ``` 118 | npm publish 119 | ``` 120 | -------------------------------------------------------------------------------- /dev/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Codemirror Minimap Demo 7 | 8 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 | 32 | 33 |
34 | 42 | 43 |
44 | 45 | 46 |
47 |
48 | 49 | 50 | 51 | 52 |
53 |
54 | 55 | 56 |
57 |
58 | 59 | 60 |
61 |
62 | 63 | 64 |
65 |
66 | 67 | 68 |
69 |
70 | 71 | 72 |
73 |
74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /dev/index.ts: -------------------------------------------------------------------------------- 1 | import { basicSetup } from "codemirror"; 2 | import { javascript } from "@codemirror/lang-javascript"; 3 | import { EditorState, Compartment, StateEffect, StateField } from "@codemirror/state"; 4 | import { EditorView, drawSelection } from "@codemirror/view"; 5 | import { linter, Diagnostic } from "@codemirror/lint"; 6 | import { syntaxTree } from "@codemirror/language"; 7 | import { oneDark } from "@codemirror/theme-one-dark"; 8 | import { Change, diff } from '@codemirror/merge' 9 | 10 | import snippets from "./snippets"; 11 | 12 | import { showMinimap } from "../src/index"; 13 | 14 | const BasicExtensions = [ 15 | basicSetup, 16 | javascript(), 17 | drawSelection(), 18 | EditorState.allowMultipleSelections.of(true), 19 | EditorView.contentAttributes.of({ 20 | /* Disabling grammarly */ 21 | "data-gramm": "false", 22 | "data-gramm_editor": "false", 23 | "data-enabled-grammarly": "false", 24 | }) 25 | ] 26 | 27 | const setShownState = StateEffect.define(); 28 | const shownState = StateField.define({ 29 | create: () => getShowMinimap(window.location.hash), 30 | update: (v, tr) => { 31 | for (const ef of tr.effects) { 32 | if (ef.is(setShownState)) { 33 | v = ef.value; 34 | } 35 | } 36 | return v; 37 | } 38 | }); 39 | 40 | const setOverlayState = StateEffect.define<"always" | "mouse-over" | undefined>(); 41 | const overlayState = StateField.define<"always" | "mouse-over" | undefined>({ 42 | create: () => getShowOverlay(window.location.hash), 43 | update: (v, tr) => { 44 | for (const ef of tr.effects) { 45 | if (ef.is(setOverlayState)) { 46 | v = ef.value; 47 | } 48 | } 49 | return v; 50 | } 51 | }); 52 | 53 | const setDisplayTextState = StateEffect.define<"blocks" | "characters" | undefined>(); 54 | const displayTextState = StateField.define<"blocks" | "characters" | undefined>({ 55 | create: () => getDisplayText(window.location.hash), 56 | update: (v, tr) => { 57 | for (const ef of tr.effects) { 58 | if (ef.is(setDisplayTextState)) { 59 | v = ef.value; 60 | } 61 | } 62 | return v; 63 | } 64 | }); 65 | 66 | const wrapCompartment = new Compartment(); 67 | function maybeWrap() { 68 | return getLineWrap(window.location.hash) ? EditorView.lineWrapping : [] 69 | } 70 | 71 | const lintCompartment = new Compartment(); 72 | function maybeLint() { 73 | return getLintingEnabled(window.location.hash) ? linter((view) => { 74 | let diagnostics: Diagnostic[] = []; 75 | syntaxTree(view.state) 76 | .cursor() 77 | .iterate((node) => { 78 | if (node.name == "RegExp") 79 | diagnostics.push({ 80 | from: node.from, 81 | to: node.to, 82 | severity: "warning", 83 | message: "Regular expressions are FORBIDDEN", 84 | actions: [ 85 | { 86 | name: "Remove", 87 | apply(view, from, to) { 88 | view.dispatch({ changes: { from, to } }); 89 | }, 90 | }, 91 | ], 92 | }); 93 | 94 | if (node.name == "BlockComment") { 95 | diagnostics.push({ 96 | from: node.from, 97 | to: node.to, 98 | severity: "error", 99 | message: "Block comments are FORBIDDEN", 100 | actions: [ 101 | { 102 | name: "Remove", 103 | apply(view, from, to) { 104 | view.dispatch({ changes: { from, to } }); 105 | }, 106 | }, 107 | ], 108 | }); 109 | } 110 | }); 111 | return diagnostics; 112 | }) : [] 113 | } 114 | 115 | const themeCompartment = new Compartment(); 116 | function maybeDark() { 117 | return getMode(window.location.hash) === 'dark' ? oneDark : [] 118 | } 119 | 120 | const diffState = StateField.define<{ original: string, changes: Array }>({ 121 | create: state => ({ original: state.doc.toString(), changes: [] }), 122 | update: (value, tr) => { 123 | if (!tr.docChanged) { 124 | return value; 125 | } 126 | 127 | return { 128 | original: value.original, 129 | changes: Array.from(diff(value.original, tr.state.doc.toString())) 130 | }; 131 | } 132 | }); 133 | 134 | 135 | 136 | const view = new EditorView({ 137 | state: EditorState.create({ 138 | doc: getDoc(window.location.hash), 139 | extensions: [ 140 | BasicExtensions, 141 | 142 | [ 143 | shownState, 144 | diffState, 145 | overlayState, 146 | displayTextState, 147 | wrapCompartment.of(maybeWrap()), 148 | lintCompartment.of(maybeLint()), 149 | themeCompartment.of(maybeDark()), 150 | ], 151 | 152 | showMinimap.compute([shownState, diffState, overlayState, displayTextState], (s) => { 153 | if (!s.field(shownState, false)) { 154 | return null; 155 | } 156 | 157 | const create = () => { 158 | const dom = document.createElement('div'); 159 | return { dom }; 160 | } 161 | 162 | const showOverlay = s.field(overlayState, false); 163 | const displayText = s.field(displayTextState, false); 164 | 165 | // TODO convert diffState -> changed line information 166 | // I'm just mocking this in for now 167 | const gutter: Record = {}; 168 | for (let i = 0; i < s.doc.lines; i++) { 169 | gutter[i] = 'green' 170 | } 171 | 172 | return { create, showOverlay, displayText, gutters: [gutter] } 173 | }), 174 | ], 175 | }), 176 | parent: document.getElementById("editor") as HTMLElement, 177 | }); 178 | 179 | /* Listen to changes and apply updates from controls */ 180 | window.addEventListener("hashchange", (e: HashChangeEvent) => { 181 | view.dispatch({ 182 | changes: { from: 0, to: view.state.doc.length, insert: getDoc(e.newURL) }, 183 | effects: [ 184 | setShownState.of(getShowMinimap(e.newURL)), 185 | setOverlayState.of(getShowOverlay(e.newURL)), 186 | setDisplayTextState.of(getDisplayText(e.newURL)), 187 | wrapCompartment.reconfigure(maybeWrap()), 188 | lintCompartment.reconfigure(maybeLint()), 189 | themeCompartment.reconfigure(maybeDark()), 190 | ] 191 | }) 192 | }); 193 | 194 | 195 | /* Helpers */ 196 | function getDoc(url: string): string { 197 | const length = getHashValue("length", url); 198 | 199 | if (length && length in snippets) { 200 | return snippets[length]; 201 | } 202 | 203 | return snippets.long; 204 | } 205 | function getShowMinimap(url: string): boolean { 206 | return getHashValue("minimap", url) !== "hide"; 207 | } 208 | function getShowOverlay(url: string): "always" | "mouse-over" | undefined { 209 | const value = getHashValue("overlay", url); 210 | if (value === "always" || value === "mouse-over") { 211 | return value; 212 | } 213 | 214 | return undefined; 215 | } 216 | function getDisplayText(url: string): "blocks" | "characters" | undefined { 217 | const value = getHashValue("text", url); 218 | if (value === "blocks" || value === "characters") { 219 | return value; 220 | } 221 | 222 | return undefined; 223 | } 224 | function getLineWrap(url: string): boolean { 225 | const value = getHashValue("wrapping", url); 226 | return value == "wrap"; 227 | } 228 | function getMode(url: string): "dark" | "light" { 229 | return getHashValue("mode", url) === "dark" ? "dark" : "light"; 230 | } 231 | function getLintingEnabled(url: string): boolean { 232 | return getHashValue("linting", url) === "disabled" ? false : true; 233 | } 234 | function getHashValue(key: string, url: string): string | undefined { 235 | const hash = url.split("#").slice(1); 236 | const pair = hash.find((kv) => kv.startsWith(`${key}=`)); 237 | return pair ? pair.split("=").slice(1)[0] : undefined; 238 | } 239 | -------------------------------------------------------------------------------- /dev/snippets.ts: -------------------------------------------------------------------------------- 1 | const short = ` 2 | function factorial(n) { 3 | if (n === 0 || n === 1) { 4 | return 1; 5 | } else { 6 | return n * factorial(n - 1); 7 | } 8 | /* Ignored */ } /* Hello world */ 9 | 10 | function isNumber(string) { 11 | return /^\d+(\.\d*)?$/.test(string) 12 | } 13 | 14 | const NUM_TRIALS = 100; 15 | const MAX_NUMBER = 100; 16 | `; 17 | 18 | const medium = ` 19 | 20 | function factorial(n) { 21 | if (n === 0 || n === 1) { 22 | return 1; 23 | } else { 24 | return n * factorial(n - 1); 25 | } 26 | /* Ignored */ } /* Hello world */ 27 | 28 | function isNumber(string) { 29 | return /^\d+(\.\d*)?$/.test(string) 30 | } 31 | 32 | const NUM_TRIALS = 100; 33 | const MAX_NUMBER = 100; 34 | 35 | function factorial(n) { 36 | if (n === 0 || n === 1) { 37 | return 1; 38 | } else { 39 | return n * factorial(n - 1); 40 | } 41 | /* Ignored */ } /* Hello world */ 42 | 43 | function isNumber(string) { 44 | return /^\d+(\.\d*)?$/.test(string) 45 | } 46 | 47 | const NUM_TRIALS = 100; 48 | const MAX_NUMBER = 100; 49 | 50 | function factorial(n) { 51 | if (n === 0 || n === 1) { 52 | return 1; 53 | } else { 54 | return n * factorial(n - 1); 55 | } 56 | /* Ignored */ } /* Hello world */ 57 | 58 | function isNumber(string) { 59 | return /^\d+(\.\d*)?$/.test(string) 60 | } 61 | 62 | const NUM_TRIALS = 100; 63 | const MAX_NUMBER = 100; 64 | 65 | function factorial(n) { 66 | if (n === 0 || n === 1) { 67 | return 1; 68 | } else { 69 | return n * factorial(n - 1); 70 | } 71 | /* Ignored */ } /* Hello world */ 72 | 73 | function isNumber(string) { 74 | return /^\d+(\.\d*)?$/.test(string) 75 | } 76 | 77 | const NUM_TRIALS = 100; 78 | const MAX_NUMBER = 100; 79 | 80 | function factorial(n) { 81 | if (n === 0 || n === 1) { 82 | return 1; 83 | } else { 84 | return n * factorial(n - 1); 85 | } 86 | /* Ignored */ } /* Hello world */ 87 | 88 | function isNumber(string) { 89 | return /^\d+(\.\d*)?$/.test(string) 90 | } 91 | 92 | const NUM_TRIALS = 100; 93 | const MAX_NUMBER = 100; 94 | `; 95 | 96 | const long = ` 97 | function factorial(n) { 98 | if (n === 0 || n === 1) { 99 | return 1; 100 | } else { 101 | return n * factorial(n - 1); 102 | } 103 | /* Ignored */ } /* Hello world */ 104 | // XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 105 | const NUM_TRIALS = 100; 106 | const MAX_NUMBER = 100; 107 | 108 | function isNumber(string) { 109 | return /^\d+(\.\d*)?$/.test(string) 110 | } 111 | /** 112 | * Multi 113 | * Line 114 | * 115 | * Comment 116 | */ 117 | 118 | function getRandomNumber(min, max) { 119 | return Math.floor(Math.random() * (max - min + 1) + min); 120 | } 121 | 122 | function isNumber2(string) { 123 | return /^\d+(\.\d*)?$/.test(string) 124 | } 125 | const min = 1; // minimum value for random number 126 | const max = 100; // maximum value for random number 127 | for (let i = 0; i < 10; i++) { // loop 10 times 128 | const randomNumber = getRandomNumber(min, max); // get a random number between min and max 129 | console.log("Random Number " + String(i+1): + String(randomNumber)); // output the random number to the console 130 | } 131 | console.log("Done!"); // output "Done!" to the console when finished 132 | let sumOfFactorials = 0; 133 | for (let i = 0; i < NUM_TRIALS; i++) { 134 | const randomInt = Math.floor(Math.random() * MAX_NUMBER) + 1; 135 | sumOfFactorials += factorial(randomInt); 136 | } /* Hello world */ 137 | console.log('The sum of factorials is: ', sumOfFactorials); 138 | const NUM_TRIALS = 100; 139 | const MAX_NUMBER = 100; 140 | 141 | function isNumber3(string) { 142 | return /^\d+(\.\d*)?$/.test(string) 143 | } 144 | /** 145 | * Multi 146 | * Line 147 | * 148 | * 149 | * 150 | * * Multi 151 | * Line 152 | * 153 | * 154 | * * Multi 155 | * Line 156 | * 157 | * XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 158 | * * Multi 159 | * Line 160 | * 161 | * 162 | * * Multi 163 | * Line 164 | * 165 | * 166 | * 167 | * * Multi 168 | * Line 169 | * 170 | * 171 | * * Multi 172 | * Line 173 | * 174 | * 175 | * * Multi 176 | * Line 177 | * 178 | * Comment 179 | */ 180 | 181 | 182 | 183 | let sumOfFactorials = 0; 184 | for (let i = 0; i < NUM_TRIALS; i++) { 185 | const randomInt = Math.floor(Math.random() * MAX_NUMBER) + 1; 186 | sumOfFactorials += factorial(randomInt); 187 | } /* Hello world */ 188 | 189 | console.log('The sum of factorials is: ', sumOfFactorials); 190 | 191 | const NUM_TRIALS = 100; 192 | const MAX_NUMBER = 100; 193 | 194 | let sumOfFactorials = 0; 195 | for (let i = 0; i < NUM_TRIALS; i++) { 196 | const randomInt = Math.floor(Math.random() * MAX_NUMBER) + 1; 197 | sumOfFactorials += factorial(randomInt); 198 | } /* Hello world */ 199 | 200 | console.log('The sum of factorials is: ', sumOfFactorials); 201 | 202 | 203 | let sumOfFactorials = 0; 204 | for (let i = 0; i < NUM_TRIALS; i++) { 205 | const randomInt = Math.floor(Math.random() * MAX_NUMBER) + 1; 206 | sumOfFactorials += factorial(randomInt); 207 | } /* Hello world */ 208 | 209 | console.log('The sum of factorials is: ', sumOfFactorials); 210 | 211 | const NUM_TRIALS = 100; 212 | const MAX_NUMBER = 100; 213 | 214 | let sumOfFactorials = 0; 215 | for (let i = 0; i < NUM_TRIALS; i++) { 216 | const randomInt = Math.floor(Math.random() * MAX_NUMBER) + 1; 217 | sumOfFactorials += factorial(randomInt); 218 | } /* Hello world */ 219 | 220 | /** 221 | * Multi 222 | * Line 223 | * 224 | * 225 | * XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 226 | * * Multi 227 | * Line 228 | * 229 | * 230 | * * * Multi 231 | * Line 232 | * 233 | * 234 | * * Multi 235 | * Line 236 | * 237 | * 238 | * * Multi 239 | * Line 240 | * 241 | * 242 | * 243 | * * Multi 244 | * Line 245 | * 246 | * * Multi 247 | * Line 248 | * 249 | * 250 | * * Multi 251 | * Line 252 | * 253 | * XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 254 | * * Multi 255 | * Line 256 | * 257 | * 258 | * 259 | * * Multi 260 | * Line 261 | * 262 | * 263 | * * Multi 264 | * Line 265 | * 266 | * 267 | * * Multi 268 | * Line 269 | * 270 | * Comment 271 | */ 272 | 273 | let sumOfFactorials = 0; 274 | for (let i = 0; i < NUM_TRIALS; i++) { 275 | const randomInt = Math.floor(Math.random() * MAX_NUMBER) + 1; 276 | sumOfFactorials += factorial(randomInt); 277 | } /* Hello world */ 278 | 279 | 280 | /** 281 | * Multi 282 | * Line 283 | * 284 | * 285 | * 286 | * * Multi 287 | * Line 288 | * 289 | * 290 | * * * Multi 291 | * Line 292 | * 293 | * 294 | * * Multi 295 | * Line 296 | * 297 | * 298 | * * Multi 299 | * Line 300 | * 301 | * 302 | * 303 | * * Multi 304 | * Line 305 | * 306 | * * Multi 307 | * Line 308 | * 309 | * 310 | * * Multi 311 | * Line * 312 | * 313 | * * * Multi 314 | * Line 315 | * 316 | * XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 317 | * * Multi 318 | * Line 319 | * 320 | * 321 | * * Multi 322 | * Line 323 | * 324 | * 325 | * 326 | * * Multi 327 | * Line 328 | * 329 | * * Multi 330 | * Line 331 | * 332 | * 333 | * * Multi 334 | * Line 335 | * 336 | * 337 | * * Multi 338 | * Line 339 | * 340 | * 341 | * 342 | * * Multi 343 | * Line 344 | * 345 | * 346 | * * Multi 347 | * Line 348 | * 349 | * 350 | * * Multi 351 | * Line 352 | * 353 | * Comment * 354 | * 355 | * * * Multi 356 | * Line 357 | * 358 | * XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 359 | * * Multi 360 | * Line 361 | * 362 | * 363 | * * Multi 364 | * Line 365 | * 366 | * 367 | * 368 | * * Multi 369 | * Line 370 | * 371 | * * Multi 372 | * Line 373 | * 374 | * 375 | * * Multi 376 | * Line 377 | * 378 | * 379 | * * Multi 380 | * Line 381 | * 382 | * 383 | * 384 | * * Multi 385 | * Line 386 | * 387 | * 388 | * * Multi 389 | * Line 390 | * 391 | * 392 | * * Multi 393 | * Line 394 | * 395 | * Comment * 396 | * 397 | * * * Multi 398 | * Line 399 | * 400 | * 401 | * * Multi 402 | * Line 403 | * 404 | * 405 | * * Multi 406 | * Line 407 | * 408 | * 409 | * 410 | * * Multi 411 | * Line 412 | * 413 | * * Multi 414 | * Line 415 | * 416 | * 417 | * * Multi 418 | * Line 419 | * 420 | * 421 | * * Multi 422 | * Line 423 | * 424 | * 425 | * 426 | * * Multi 427 | * Line 428 | * 429 | * 430 | * * Multi 431 | * Line 432 | * XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 433 | * 434 | * * Multi 435 | * Line 436 | * 437 | * Comment 438 | * 439 | * 440 | * * Multi 441 | * Line 442 | * 443 | * 444 | * 445 | * * Multi 446 | * Line 447 | * 448 | * 449 | * * Multi 450 | * Line 451 | * 452 | * 453 | * * Multi 454 | * Line 455 | * 456 | * Comment 457 | */ 458 | 459 | console.log('The sum of factorials is: ', sumOfFactorials); 460 | 461 | let sumOfFactorials = 0; 462 | for (let i = 0; i < NUM_TRIALS; i++) { 463 | const randomInt = Math.floor(Math.random() * MAX_NUMBER) + 1; 464 | sumOfFactorials += factorial(randomInt); 465 | } /* Hello world */ 466 | 467 | console.log('The sum of factorials is: ', sumOfFactorials); 468 | 469 | const NUM_TRIALS = 100; 470 | const MAX_NUMBER = 100; 471 | 472 | let sumOfFactorials = 0; 473 | for (let i = 0; i < NUM_TRIALS; i++) { 474 | const randomInt = Math.floor(Math.random() * MAX_NUMBER) + 1; 475 | sumOfFactorials += factorial(randomInt); 476 | } /* Hello world */ 477 | 478 | console.log('The sum of factorials is: ', sumOfFactorials); 479 | 480 | let sumOfFactorials = 0; 481 | for (let i = 0; i < NUM_TRIALS; i++) { 482 | const randomInt = Math.floor(Math.random() * MAX_NUMBER) + 1; 483 | sumOfFactorials += factorial(randomInt); 484 | } /* Hello world */ 485 | 486 | console.log('The sum of factorials is: ', sumOfFactorials); 487 | 488 | const NUM_TRIALS = 100; 489 | const MAX_NUMBER = 100; 490 | 491 | let sumOfFactorials = 0; 492 | for (let i = 0; i < NUM_TRIALS; i++) { 493 | const randomInt = Math.floor(Math.random() * MAX_NUMBER) + 1; 494 | sumOfFactorials += factorial(randomInt); 495 | } /* Hello world */ 496 | 497 | console.log('The sum of factorials is: ', sumOfFactorials); 498 | `; 499 | 500 | import { massive } from "./massive"; 501 | 502 | export default { short, medium, long, massive }; 503 | -------------------------------------------------------------------------------- /dev/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | 3 | export default defineConfig({ 4 | server: { 5 | host: "0.0.0.0", 6 | }, 7 | }); 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@replit/codemirror-minimap", 3 | "version": "0.5.2", 4 | "author": { 5 | "name": "Brady Madden", 6 | "email": "brady@repl.it" 7 | }, 8 | "type": "module", 9 | "main": "./dist/index.cjs", 10 | "exports": { 11 | "import": "./dist/index.js", 12 | "require": "./dist/index.cjs" 13 | }, 14 | "types": "dist/index.d.ts", 15 | "module": "dist/index.js", 16 | "sideEffects": false, 17 | "license": "MIT", 18 | "scripts": { 19 | "dev": "vite ./dev", 20 | "build": "cm-buildhelper ./src/index.ts", 21 | "test": "cm-runtests" 22 | }, 23 | "dependencies": { 24 | "crelt": "^1.0.5" 25 | }, 26 | "peerDependencies": { 27 | "@codemirror/language": "^6.9.1", 28 | "@codemirror/lint": "^6.4.2", 29 | "@codemirror/state": "^6.3.1", 30 | "@codemirror/view": "^6.21.3", 31 | "@lezer/common": "^1.1.0", 32 | "@lezer/highlight": "^1.1.6" 33 | }, 34 | "devDependencies": { 35 | "@codemirror/language": "^6.9.1", 36 | "@codemirror/lint": "^6.4.2", 37 | "@codemirror/state": "^6.3.1", 38 | "@codemirror/view": "^6.21.3", 39 | "@lezer/common": "^1.1.0", 40 | "@lezer/highlight": "^1.1.6", 41 | "@codemirror/buildhelper": "^0.1.16", 42 | "@codemirror/lang-javascript": "^6.1.4", 43 | "@codemirror/merge": "^6.1.1", 44 | "@codemirror/theme-one-dark": "^6.1.1", 45 | "codemirror": "^6.0.1", 46 | "typescript": "^5.0.2", 47 | "vite": "^4.4.5" 48 | }, 49 | "repository": { 50 | "type": "git", 51 | "url": "https://github.com/replit/codemirror-minimap.git" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Config.ts: -------------------------------------------------------------------------------- 1 | import { Facet, combineConfig } from "@codemirror/state"; 2 | import { DOMEventMap, EditorView } from "@codemirror/view"; 3 | import { MinimapConfig } from "."; 4 | import { Gutter } from "./Gutters"; 5 | 6 | type EventHandler = ( 7 | e: DOMEventMap[event], 8 | v: EditorView 9 | ) => void; 10 | 11 | type Options = { 12 | /** 13 | * Controls whether the minimap should be hidden on mouseout. 14 | * Defaults to `false`. 15 | */ 16 | autohide?: boolean; 17 | 18 | enabled: boolean; 19 | 20 | /** 21 | * Determines how to render text. Defaults to `characters`. 22 | */ 23 | displayText?: "blocks" | "characters"; 24 | 25 | /** 26 | * Attach event handlers to the minimap container element. 27 | */ 28 | eventHandlers?: { 29 | [event in keyof DOMEventMap]?: EventHandler; 30 | }; 31 | 32 | /** 33 | * The overlay shows the portion of the file currently in the viewport. 34 | * Defaults to `always`. 35 | */ 36 | showOverlay?: "always" | "mouse-over"; 37 | 38 | /** 39 | * Enables a gutter to be drawn on the given line to the left 40 | * of the minimap, with the given color. Accepts all valid CSS 41 | * color values. 42 | */ 43 | gutters?: Array; 44 | }; 45 | 46 | const Config = Facet.define>({ 47 | combine: (c) => { 48 | const configs: Array = []; 49 | for (let config of c) { 50 | if (!config) { 51 | continue; 52 | } 53 | 54 | const { create, gutters, ...rest } = config; 55 | 56 | configs.push({ 57 | ...rest, 58 | enabled: true, 59 | gutters: gutters 60 | ? gutters.filter((v) => Object.keys(v).length > 0) 61 | : undefined, 62 | }); 63 | } 64 | 65 | return combineConfig(configs, { 66 | enabled: configs.length > 0, 67 | displayText: "characters", 68 | eventHandlers: {}, 69 | showOverlay: "always", 70 | gutters: [], 71 | autohide: false, 72 | }); 73 | }, 74 | }); 75 | 76 | const Scale = { 77 | // Multiply the number of canvas pixels 78 | PixelMultiplier: 2, 79 | // Downscale the editor contents by this ratio 80 | SizeRatio: 4, 81 | // Maximum width of the minimap in pixels 82 | MaxWidth: 120, 83 | } as const; 84 | 85 | export { Config, Options, Scale }; 86 | -------------------------------------------------------------------------------- /src/Gutters.ts: -------------------------------------------------------------------------------- 1 | import { DrawContext } from "./types"; 2 | 3 | const GUTTER_WIDTH = 4; 4 | 5 | type Line = number; 6 | type Color = string; 7 | export type Gutter = Record; 8 | 9 | 10 | /** 11 | * Draws a gutter to the canvas context for the given line number 12 | */ 13 | function drawLineGutter(gutter: Record, ctx: DrawContext, lineNumber: number) { 14 | const color = gutter[lineNumber]; 15 | if (!color) { 16 | return; 17 | } 18 | 19 | ctx.context.fillStyle = color; 20 | ctx.context.globalAlpha = 1; 21 | ctx.context.beginPath(); 22 | ctx.context.rect(ctx.offsetX, ctx.offsetY, GUTTER_WIDTH, ctx.lineHeight); 23 | ctx.context.fill(); 24 | } 25 | 26 | 27 | export { GUTTER_WIDTH, drawLineGutter } -------------------------------------------------------------------------------- /src/LinesState.ts: -------------------------------------------------------------------------------- 1 | import { foldEffect, foldedRanges, unfoldEffect } from "@codemirror/language"; 2 | import { StateField, EditorState, Transaction } from "@codemirror/state"; 3 | import { Config } from "./Config"; 4 | 5 | type Span = { from: number; to: number; folded: boolean }; 6 | type Line = Array; 7 | type Lines = Array; 8 | 9 | function computeLinesState(state: EditorState): Lines { 10 | if (!state.facet(Config).enabled) { 11 | return []; 12 | } 13 | 14 | const lines: Lines = []; 15 | 16 | const lineCursor = state.doc.iterLines(); 17 | const foldedRangeCursor = foldedRanges(state).iter(); 18 | 19 | let textOffset = 0; 20 | lineCursor.next(); 21 | 22 | while (!lineCursor.done) { 23 | const lineText = lineCursor.value; 24 | let from = textOffset; 25 | let to = from + lineText.length; 26 | 27 | // Iterate through folded ranges until we're at or past the current line 28 | while (foldedRangeCursor.value && foldedRangeCursor.to < from) { 29 | foldedRangeCursor.next(); 30 | } 31 | const { from: foldFrom, to: foldTo } = foldedRangeCursor; 32 | 33 | const lineStartInFold = from >= foldFrom && from < foldTo; 34 | const lineEndsInFold = to > foldFrom && to <= foldTo; 35 | 36 | if (lineStartInFold) { 37 | let lastLine = lines.pop() ?? []; 38 | let lastRange = lastLine.pop(); 39 | 40 | // If the last range is folded, we extend the folded range 41 | if (lastRange && lastRange.folded) { 42 | lastRange.to = foldTo; 43 | } 44 | 45 | // If we popped the last range, add it back 46 | if (lastRange) { 47 | lastLine.push(lastRange); 48 | } 49 | 50 | // If we didn't have a previous range, or the previous range wasn't folded add a new range 51 | if (!lastRange || !lastRange.folded) { 52 | lastLine.push({ from: foldFrom, to: foldTo, folded: true }); 53 | } 54 | 55 | // If the line doesn't end in a fold, we add another token for the unfolded section 56 | if (!lineEndsInFold) { 57 | lastLine.push({ from: foldTo, to, folded: false }); 58 | } 59 | 60 | lines.push(lastLine); 61 | } else if (lineEndsInFold) { 62 | lines.push([ 63 | { from, to: foldFrom, folded: false }, 64 | { from: foldFrom, to: foldTo, folded: true }, 65 | ]); 66 | } else { 67 | lines.push([{ from, to, folded: false }]); 68 | } 69 | 70 | textOffset = to + 1; 71 | lineCursor.next(); 72 | } 73 | 74 | return lines; 75 | } 76 | 77 | const LinesState = StateField.define({ 78 | create: (state) => computeLinesState(state), 79 | update: (current, tr) => { 80 | if (foldsChanged([tr]) || tr.docChanged) { 81 | return computeLinesState(tr.state); 82 | } 83 | 84 | return current; 85 | }, 86 | }); 87 | 88 | /** Returns if the folds have changed in this update */ 89 | function foldsChanged(transactions: readonly Transaction[]) { 90 | return transactions.find((tr) => 91 | tr.effects.find((ef) => ef.is(foldEffect) || ef.is(unfoldEffect)) 92 | ); 93 | } 94 | 95 | export { foldsChanged, LinesState, Lines }; 96 | -------------------------------------------------------------------------------- /src/Overlay.ts: -------------------------------------------------------------------------------- 1 | import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view"; 2 | import { Config, Scale } from "./Config"; 3 | import crelt from "crelt"; 4 | 5 | const Theme = EditorView.theme({ 6 | ".cm-minimap-overlay-container": { 7 | position: "absolute", 8 | top: 0, 9 | height: "100%", 10 | width: "100%", 11 | "&.cm-minimap-overlay-mouse-over": { 12 | opacity: 0, 13 | transition: "visibility 0s linear 300ms, opacity 300ms", 14 | }, 15 | "&.cm-minimap-overlay-mouse-over:hover": { 16 | opacity: 1, 17 | transition: "visibility 0s linear 0ms, opacity 300ms", 18 | }, 19 | "&.cm-minimap-overlay-off": { 20 | display: "none", 21 | }, 22 | "& .cm-minimap-overlay": { 23 | background: "rgb(121, 121, 121)", 24 | opacity: "0.2", 25 | position: "absolute", 26 | right: 0, 27 | top: 0, 28 | width: "100%", 29 | transition: "top 0s ease-in 0ms", 30 | "&:hover": { 31 | opacity: "0.3", 32 | }, 33 | }, 34 | "&.cm-minimap-overlay-active": { 35 | opacity: 1, 36 | visibility: "visible", 37 | transition: "visibility 0s linear 0ms, opacity 300ms", 38 | "& .cm-minimap-overlay": { 39 | opacity: "0.4", 40 | }, 41 | }, 42 | }, 43 | }); 44 | 45 | const SCALE = Scale.PixelMultiplier * Scale.SizeRatio; 46 | 47 | const OverlayView = ViewPlugin.fromClass( 48 | class { 49 | private container: HTMLElement | undefined; 50 | private dom: HTMLElement | undefined; 51 | 52 | private _isDragging: boolean = false; 53 | private _dragStartY: number | undefined; 54 | 55 | public constructor(private view: EditorView) { 56 | if (view.state.facet(Config).enabled) { 57 | this.create(view); 58 | } 59 | } 60 | 61 | private create(view: EditorView) { 62 | this.container = crelt("div", { class: "cm-minimap-overlay-container" }); 63 | this.dom = crelt("div", { class: "cm-minimap-overlay" }); 64 | this.container.appendChild(this.dom); 65 | 66 | // Attach event listeners for overlay 67 | this.container.addEventListener("mousedown", this.onMouseDown.bind(this)); 68 | window.addEventListener("mouseup", this.onMouseUp.bind(this)); 69 | window.addEventListener("mousemove", this.onMouseMove.bind(this)); 70 | 71 | // Attach the overlay elements to the minimap 72 | const inner = view.dom.querySelector(".cm-minimap-inner"); 73 | if (inner) { 74 | inner.appendChild(this.container); 75 | } 76 | 77 | // Initially set overlay configuration styles, height, top 78 | this.computeShowOverlay(); 79 | this.computeHeight(); 80 | this.computeTop(); 81 | } 82 | 83 | private remove() { 84 | if (this.container) { 85 | this.container.removeEventListener("mousedown", this.onMouseDown); 86 | window.removeEventListener("mouseup", this.onMouseUp); 87 | window.removeEventListener("mousemove", this.onMouseMove); 88 | this.container.remove(); 89 | } 90 | } 91 | 92 | update(update: ViewUpdate) { 93 | const prev = update.startState.facet(Config).enabled; 94 | const now = update.state.facet(Config).enabled; 95 | 96 | if (prev && !now) { 97 | this.remove(); 98 | return; 99 | } 100 | 101 | if (!prev && now) { 102 | this.create(update.view); 103 | } 104 | 105 | if (now) { 106 | this.computeShowOverlay(); 107 | 108 | if (update.geometryChanged) { 109 | this.computeHeight(); 110 | this.computeTop(); 111 | } 112 | } 113 | } 114 | 115 | public computeHeight() { 116 | if (!this.dom) { 117 | return; 118 | } 119 | 120 | const height = this.view.dom.clientHeight / SCALE; 121 | this.dom.style.height = height + "px"; 122 | } 123 | 124 | public computeTop() { 125 | if (!this._isDragging && this.dom) { 126 | const { clientHeight, scrollHeight, scrollTop } = this.view.scrollDOM; 127 | 128 | const maxScrollTop = scrollHeight - clientHeight; 129 | const topForNonOverflowing = scrollTop / SCALE; 130 | 131 | const height = clientHeight / SCALE; 132 | const maxTop = clientHeight - height; 133 | let scrollRatio = scrollTop / maxScrollTop; 134 | if (isNaN(scrollRatio)) scrollRatio = 0; 135 | const topForOverflowing = maxTop * scrollRatio; 136 | 137 | const top = Math.min(topForOverflowing, topForNonOverflowing); 138 | this.dom.style.top = top + "px"; 139 | } 140 | } 141 | 142 | public computeShowOverlay() { 143 | if (!this.container) { 144 | return; 145 | } 146 | 147 | const { showOverlay } = this.view.state.facet(Config); 148 | 149 | if (showOverlay === "mouse-over") { 150 | this.container.classList.add("cm-minimap-overlay-mouse-over"); 151 | } else { 152 | this.container.classList.remove("cm-minimap-overlay-mouse-over"); 153 | } 154 | 155 | const { clientHeight, scrollHeight } = this.view.scrollDOM; 156 | if (clientHeight === scrollHeight) { 157 | this.container.classList.add("cm-minimap-overlay-off"); 158 | } else { 159 | this.container.classList.remove("cm-minimap-overlay-off"); 160 | } 161 | } 162 | 163 | private onMouseDown(event: MouseEvent) { 164 | if (!this.container) { 165 | return; 166 | } 167 | 168 | // Ignore right click 169 | if (event.button === 2) { 170 | return; 171 | } 172 | 173 | // If target is the overlay start dragging 174 | const { clientY, target } = event; 175 | if (target === this.dom) { 176 | this._dragStartY = event.clientY; 177 | this._isDragging = true; 178 | this.container.classList.add("cm-minimap-overlay-active"); 179 | return; 180 | } 181 | 182 | // Updates the scroll position of the EditorView based on the 183 | // position of the MouseEvent on the minimap canvas 184 | const { clientHeight, scrollHeight, scrollTop } = this.view.scrollDOM; 185 | const targetTop = (target as HTMLElement).getBoundingClientRect().top; 186 | const deltaY = (clientY - targetTop) * SCALE; 187 | 188 | const scrollRatio = scrollTop / (scrollHeight - clientHeight); 189 | const visibleRange = clientHeight * SCALE - clientHeight; 190 | const visibleTop = visibleRange * scrollRatio; 191 | 192 | const top = Math.max(0, scrollTop - visibleTop); 193 | this.view.scrollDOM.scrollTop = top + deltaY - clientHeight / 2; 194 | } 195 | 196 | private onMouseUp(_event: MouseEvent) { 197 | // Stop dragging on mouseup 198 | if (this._isDragging && this.container) { 199 | this._dragStartY = undefined; 200 | this._isDragging = false; 201 | this.container.classList.remove("cm-minimap-overlay-active"); 202 | } 203 | } 204 | 205 | private onMouseMove(event: MouseEvent) { 206 | if (!this._isDragging || !this.dom) { 207 | return; 208 | } 209 | 210 | event.preventDefault(); 211 | event.stopPropagation(); 212 | 213 | // Without an existing position, we're just beginning to drag. 214 | if (!this._dragStartY) { 215 | this._dragStartY = event.clientY; 216 | return; 217 | } 218 | 219 | const deltaY = event.clientY - this._dragStartY; 220 | const movingUp = deltaY < 0; 221 | const movingDown = deltaY > 0; 222 | 223 | // Update drag position for the next tick 224 | this._dragStartY = event.clientY; 225 | 226 | const canvasHeight = this.dom.getBoundingClientRect().height; 227 | const canvasAbsTop = this.dom.getBoundingClientRect().y; 228 | const canvasAbsBot = canvasAbsTop + canvasHeight; 229 | const canvasRelTopDouble = parseFloat(this.dom.style.top); 230 | 231 | const scrollPosition = this.view.scrollDOM.scrollTop; 232 | const editorHeight = this.view.scrollDOM.clientHeight; 233 | const contentHeight = this.view.scrollDOM.scrollHeight; 234 | 235 | const atTop = scrollPosition === 0; 236 | const atBottom = 237 | Math.round(scrollPosition) >= Math.round(contentHeight - editorHeight); 238 | 239 | // We allow over-dragging past the top/bottom, but the overlay just sticks 240 | // to the top or bottom of its range. These checks prevent us from immediately 241 | // moving the overlay when the drag changes direction. We should wait until 242 | // the cursor has returned to, and begun to pass the bottom/top of the range 243 | if ((atTop && movingUp) || (atTop && event.clientY < canvasAbsTop)) { 244 | return; 245 | } 246 | if ( 247 | (atBottom && movingDown) || 248 | (atBottom && event.clientY > canvasAbsBot) 249 | ) { 250 | return; 251 | } 252 | 253 | // Set view scroll directly 254 | const scrollHeight = this.view.scrollDOM.scrollHeight; 255 | const clientHeight = this.view.scrollDOM.clientHeight; 256 | 257 | const maxTopNonOverflowing = (scrollHeight - clientHeight) / SCALE; 258 | const maxTopOverflowing = clientHeight - clientHeight / SCALE; 259 | 260 | const change = canvasRelTopDouble + deltaY; 261 | 262 | /** 263 | * ScrollPosOverflowing is calculated by: 264 | * - Calculating the offset (change) relative to the total height of the container 265 | * - Multiplying by the maximum scrollTop position for the scroller 266 | * - The maximum scrollTop position for the scroller is the total scroll height minus the client height 267 | */ 268 | const relativeToMax = change / maxTopOverflowing; 269 | const scrollPosOverflowing = 270 | (scrollHeight - clientHeight) * relativeToMax; 271 | 272 | const scrollPosNonOverflowing = change * SCALE; 273 | this.view.scrollDOM.scrollTop = Math.max( 274 | scrollPosOverflowing, 275 | scrollPosNonOverflowing 276 | ); 277 | 278 | // view.scrollDOM truncates if out of bounds. We need to mimic that behavior here with min/max guard 279 | const top = Math.min( 280 | Math.max(0, change), 281 | Math.min(maxTopOverflowing, maxTopNonOverflowing) 282 | ); 283 | this.dom.style.top = top + "px"; 284 | } 285 | 286 | public destroy() { 287 | this.remove(); 288 | } 289 | }, 290 | { 291 | eventHandlers: { 292 | scroll() { 293 | requestAnimationFrame(() => this.computeTop()); 294 | }, 295 | }, 296 | } 297 | ); 298 | 299 | export const Overlay = [Theme, OverlayView]; 300 | -------------------------------------------------------------------------------- /src/diagnostics.ts: -------------------------------------------------------------------------------- 1 | import { EditorView, ViewUpdate } from "@codemirror/view"; 2 | import { 3 | Diagnostic, 4 | diagnosticCount, 5 | forEachDiagnostic, 6 | setDiagnosticsEffect, 7 | } from "@codemirror/lint"; 8 | 9 | import { LineBasedState } from "./linebasedstate"; 10 | import { DrawContext } from "./types"; 11 | import { Lines, LinesState, foldsChanged } from "./LinesState"; 12 | import { Config } from "./Config"; 13 | 14 | type Severity = Diagnostic["severity"]; 15 | 16 | export class DiagnosticState extends LineBasedState { 17 | private count: number | undefined = undefined; 18 | 19 | public constructor(view: EditorView) { 20 | super(view); 21 | } 22 | 23 | private shouldUpdate(update: ViewUpdate) { 24 | // If the minimap is disabled 25 | if (!update.state.facet(Config).enabled) { 26 | return false; 27 | } 28 | 29 | // If the doc changed 30 | if (update.docChanged) { 31 | return true; 32 | } 33 | 34 | // If the diagnostics changed 35 | for (const tr of update.transactions) { 36 | for (const ef of tr.effects) { 37 | if (ef.is(setDiagnosticsEffect)) { 38 | return true; 39 | } 40 | } 41 | } 42 | 43 | // If the folds changed 44 | if (foldsChanged(update.transactions)) { 45 | return true; 46 | } 47 | 48 | // If the minimap was previously hidden 49 | if (this.count === undefined) { 50 | return true; 51 | } 52 | 53 | return false; 54 | } 55 | 56 | public update(update: ViewUpdate) { 57 | if (!this.shouldUpdate(update)) { 58 | return; 59 | } 60 | 61 | this.map.clear(); 62 | const lines = update.state.field(LinesState); 63 | this.count = diagnosticCount(update.state); 64 | 65 | forEachDiagnostic(update.state, (diagnostic, from, to) => { 66 | // Find the start and end lines for the diagnostic 67 | const lineStart = this.findLine(from, lines); 68 | const lineEnd = this.findLine(to, lines); 69 | 70 | // Populate each line in the range with the highest severity diagnostic 71 | let severity = diagnostic.severity; 72 | for (let i = lineStart; i <= lineEnd; i++) { 73 | const previous = this.get(i); 74 | if (previous) { 75 | severity = [severity, previous] 76 | .sort(this.sort.bind(this)) 77 | .slice(0, 1)[0]; 78 | } 79 | this.set(i, severity); 80 | } 81 | }); 82 | } 83 | 84 | public drawLine(ctx: DrawContext, lineNumber: number) { 85 | const { context, lineHeight, offsetX, offsetY } = ctx; 86 | const severity = this.get(lineNumber); 87 | if (!severity) { 88 | return; 89 | } 90 | 91 | // Draw the full line width rectangle in the background 92 | context.globalAlpha = 0.65; 93 | context.beginPath(); 94 | context.rect( 95 | offsetX, 96 | offsetY /* TODO Scaling causes anti-aliasing in rectangles */, 97 | context.canvas.width - offsetX, 98 | lineHeight 99 | ); 100 | context.fillStyle = this.color(severity); 101 | context.fill(); 102 | 103 | // Draw diagnostic range rectangle in the foreground 104 | // TODO: We need to update the state to have specific ranges 105 | // context.globalAlpha = 1; 106 | // context.beginPath(); 107 | // context.rect(offsetX, offsetY, textWidth, lineHeight); 108 | // context.fillStyle = this.color(severity); 109 | // context.fill(); 110 | } 111 | 112 | /** 113 | * Given a position and a set of line ranges, return 114 | * the line number the position falls within 115 | */ 116 | private findLine(pos: number, lines: Lines) { 117 | const index = lines.findIndex((spans) => { 118 | const start = spans.slice(0, 1)[0]; 119 | const end = spans.slice(-1)[0]; 120 | 121 | if (!start || !end) { 122 | return false; 123 | } 124 | 125 | return start.from <= pos && pos <= end.to; 126 | }); 127 | 128 | // Line numbers begin at 1 129 | return index + 1; 130 | } 131 | 132 | /** 133 | * Colors from @codemirror/lint 134 | * https://github.com/codemirror/lint/blob/e0671b43c02e72766ad1afe1579b7032fdcdb6c1/src/lint.ts#L597 135 | */ 136 | private color(severity: Severity) { 137 | return severity === "error" 138 | ? "#d11" 139 | : severity === "warning" 140 | ? "orange" 141 | : "#999"; 142 | } 143 | 144 | /** Sorts severity from most to least severe */ 145 | private sort(a: Severity, b: Severity) { 146 | return this.score(b) - this.score(a); 147 | } 148 | 149 | /** Assigns a score to severity, with most severe being the highest */ 150 | private score(s: Severity) { 151 | switch (s) { 152 | case "error": { 153 | return 3; 154 | } 155 | case "warning": { 156 | return 2; 157 | } 158 | default: { 159 | return 1; 160 | } 161 | } 162 | } 163 | } 164 | 165 | export function diagnostics(view: EditorView): DiagnosticState { 166 | return new DiagnosticState(view); 167 | } 168 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Facet } from "@codemirror/state"; 2 | import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view"; 3 | import { Overlay } from "./Overlay"; 4 | import { Config, Options, Scale } from "./Config"; 5 | import { DiagnosticState, diagnostics } from "./diagnostics"; 6 | import { SelectionState, selections } from "./selections"; 7 | import { TextState, text } from "./text"; 8 | import { LinesState } from "./LinesState"; 9 | import crelt from "crelt"; 10 | import { GUTTER_WIDTH, drawLineGutter } from "./Gutters"; 11 | 12 | const Theme = EditorView.theme({ 13 | "&": { 14 | height: "100%", 15 | overflowY: "auto", 16 | }, 17 | "& .cm-minimap-gutter": { 18 | borderRight: 0, 19 | flexShrink: 0, 20 | left: "unset", 21 | position: "sticky", 22 | right: 0, 23 | top: 0, 24 | }, 25 | '& .cm-minimap-autohide': { 26 | opacity: 0.0, 27 | transition: 'opacity 0.3s', 28 | }, 29 | '& .cm-minimap-autohide:hover': { 30 | opacity: 1.0, 31 | }, 32 | "& .cm-minimap-inner": { 33 | height: "100%", 34 | position: "absolute", 35 | right: 0, 36 | top: 0, 37 | overflowY: "hidden", 38 | "& canvas": { 39 | display: "block", 40 | }, 41 | }, 42 | "& .cm-minimap-box-shadow": { 43 | boxShadow: "12px 0px 20px 5px #6c6c6c", 44 | }, 45 | }); 46 | 47 | const WIDTH_RATIO = 6; 48 | 49 | const minimapClass = ViewPlugin.fromClass( 50 | class { 51 | private dom: HTMLElement | undefined; 52 | private inner: HTMLElement | undefined; 53 | private canvas: HTMLCanvasElement | undefined; 54 | 55 | public text: TextState; 56 | public selection: SelectionState; 57 | public diagnostic: DiagnosticState; 58 | 59 | public constructor(private view: EditorView) { 60 | this.text = text(view); 61 | this.selection = selections(view); 62 | this.diagnostic = diagnostics(view); 63 | 64 | if (view.state.facet(showMinimap)) { 65 | this.create(view); 66 | } 67 | } 68 | 69 | private create(view: EditorView) { 70 | const config = view.state.facet(showMinimap); 71 | if (!config) { 72 | throw Error("Expected nonnull"); 73 | } 74 | 75 | this.inner = crelt("div", { class: "cm-minimap-inner" }); 76 | this.canvas = crelt("canvas") as HTMLCanvasElement; 77 | 78 | this.dom = config.create(view).dom; 79 | this.dom.classList.add("cm-gutters"); 80 | this.dom.classList.add("cm-minimap-gutter"); 81 | 82 | this.inner.appendChild(this.canvas); 83 | this.dom.appendChild(this.inner); 84 | 85 | // For now let's keep this same behavior. We might want to change 86 | // this in the future and have the extension figure out how to mount. 87 | // Or expose some more generic right gutter api and use that 88 | this.view.scrollDOM.insertBefore( 89 | this.dom, 90 | this.view.contentDOM.nextSibling 91 | ); 92 | 93 | for (const key in this.view.state.facet(Config).eventHandlers) { 94 | const handler = this.view.state.facet(Config).eventHandlers[key]; 95 | if (handler) { 96 | this.dom.addEventListener(key, (e) => handler(e, this.view)); 97 | } 98 | } 99 | 100 | if (config.autohide) { 101 | this.dom.classList.add('cm-minimap-autohide'); 102 | } 103 | } 104 | 105 | private remove() { 106 | if (this.dom) { 107 | this.dom.remove(); 108 | } 109 | } 110 | 111 | update(update: ViewUpdate) { 112 | const prev = update.startState.facet(showMinimap); 113 | const now = update.state.facet(showMinimap); 114 | 115 | if (prev && !now) { 116 | this.remove(); 117 | return; 118 | } 119 | 120 | if (!prev && now) { 121 | this.create(update.view); 122 | } 123 | 124 | if (now) { 125 | this.text.update(update); 126 | this.selection.update(update); 127 | this.diagnostic.update(update); 128 | this.render(); 129 | } 130 | } 131 | 132 | getWidth(): number { 133 | const editorWidth = this.view.dom.clientWidth; 134 | if (editorWidth <= Scale.MaxWidth * WIDTH_RATIO) { 135 | const ratio = editorWidth / (Scale.MaxWidth * WIDTH_RATIO); 136 | return Scale.MaxWidth * ratio; 137 | } 138 | return Scale.MaxWidth; 139 | } 140 | 141 | render() { 142 | // If we don't have elements to draw to exit early 143 | if (!this.dom || !this.canvas || !this.inner) { 144 | return; 145 | } 146 | 147 | this.text.beforeDraw(); 148 | 149 | this.updateBoxShadow(); 150 | 151 | this.dom.style.width = this.getWidth() + "px"; 152 | this.canvas.style.maxWidth = this.getWidth() + "px"; 153 | this.canvas.width = this.getWidth() * Scale.PixelMultiplier; 154 | 155 | const domHeight = this.view.dom.getBoundingClientRect().height; 156 | this.inner.style.minHeight = domHeight + "px"; 157 | this.canvas.height = domHeight * Scale.PixelMultiplier; 158 | this.canvas.style.height = domHeight + "px"; 159 | 160 | const context = this.canvas.getContext("2d"); 161 | if (!context) { 162 | return; 163 | } 164 | 165 | context.clearRect(0, 0, this.canvas.width, this.canvas.height); 166 | 167 | /* We need to get the correct font dimensions before this to measure characters */ 168 | const { charWidth, lineHeight } = this.text.measure(context); 169 | 170 | let { startIndex, endIndex, offsetY } = this.canvasStartAndEndIndex( 171 | context, 172 | lineHeight 173 | ); 174 | 175 | const gutters = this.view.state.facet(Config).gutters; 176 | 177 | for (let i = startIndex; i < endIndex; i++) { 178 | const lines = this.view.state.field(LinesState); 179 | if (i >= lines.length) break; 180 | 181 | const drawContext = { 182 | offsetX: 0, 183 | offsetY, 184 | context, 185 | lineHeight, 186 | charWidth, 187 | }; 188 | 189 | if (gutters.length) { 190 | /* Small leading buffer */ 191 | drawContext.offsetX += 2; 192 | 193 | for (let gutter of gutters) { 194 | drawLineGutter(gutter, drawContext, i + 1); 195 | drawContext.offsetX += GUTTER_WIDTH; 196 | } 197 | 198 | /* Small trailing buffer */ 199 | drawContext.offsetX += 2; 200 | } 201 | 202 | this.text.drawLine(drawContext, i + 1); 203 | this.selection.drawLine(drawContext, i + 1); 204 | this.diagnostic.drawLine(drawContext, i + 1); 205 | 206 | offsetY += lineHeight; 207 | } 208 | 209 | context.restore(); 210 | } 211 | 212 | private canvasStartAndEndIndex( 213 | context: CanvasRenderingContext2D, 214 | lineHeight: number 215 | ) { 216 | let { top: pTop, bottom: pBottom } = this.view.documentPadding; 217 | (pTop /= Scale.SizeRatio), (pBottom /= Scale.SizeRatio); 218 | 219 | const canvasHeight = context.canvas.height; 220 | const { clientHeight, scrollHeight, scrollTop } = this.view.scrollDOM; 221 | let scrollPercent = scrollTop / (scrollHeight - clientHeight); 222 | if (isNaN(scrollPercent)) { 223 | scrollPercent = 0; 224 | } 225 | 226 | const lineCount = this.view.state.field(LinesState).length; 227 | const totalHeight = pTop + pBottom + lineCount * lineHeight; 228 | 229 | const canvasTop = Math.max( 230 | 0, 231 | scrollPercent * (totalHeight - canvasHeight) 232 | ); 233 | const offsetY = Math.max(0, pTop - canvasTop); 234 | 235 | const startIndex = Math.round(Math.max(0, canvasTop - pTop) / lineHeight); 236 | const spaceForLines = Math.round((canvasHeight - offsetY) / lineHeight); 237 | 238 | return { 239 | startIndex, 240 | endIndex: startIndex + spaceForLines, 241 | offsetY, 242 | }; 243 | } 244 | 245 | private updateBoxShadow() { 246 | if (!this.canvas) { 247 | return; 248 | } 249 | 250 | const { clientWidth, scrollWidth, scrollLeft } = this.view.scrollDOM; 251 | 252 | if (clientWidth + scrollLeft < scrollWidth) { 253 | this.canvas.classList.add("cm-minimap-box-shadow"); 254 | } else { 255 | this.canvas.classList.remove("cm-minimap-box-shadow"); 256 | } 257 | } 258 | 259 | destroy() { 260 | this.remove(); 261 | } 262 | }, 263 | { 264 | eventHandlers: { 265 | scroll() { 266 | requestAnimationFrame(() => this.render()); 267 | }, 268 | }, 269 | provide: (plugin) => { 270 | return EditorView.scrollMargins.of((view) => { 271 | const width = view.plugin(plugin)?.getWidth(); 272 | if (!width) { 273 | return null; 274 | } 275 | 276 | return { right: width }; 277 | }); 278 | }, 279 | } 280 | ); 281 | 282 | export interface MinimapConfig extends Omit { 283 | /** 284 | * A function that creates the element that contains the minimap 285 | */ 286 | create: (view: EditorView) => { dom: HTMLElement }; 287 | } 288 | 289 | /** 290 | * Facet used to show a minimap in the right gutter of the editor using the 291 | * provided configuration. 292 | * 293 | * If you return `null`, a minimap will not be shown. 294 | */ 295 | const showMinimap = Facet.define({ 296 | combine: (c) => c.find((o) => o !== null) ?? null, 297 | enables: (f) => { 298 | return [ 299 | [ 300 | Config.compute([f], (s) => s.facet(f)), 301 | Theme, 302 | LinesState, 303 | minimapClass, // TODO, codemirror-ify this one better 304 | Overlay, 305 | ], 306 | ]; 307 | }, 308 | }); 309 | 310 | export { showMinimap }; 311 | -------------------------------------------------------------------------------- /src/linebasedstate.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from "@codemirror/view"; 2 | 3 | // TODO: renamed this file because something's weird with codemirror build 4 | 5 | export abstract class LineBasedState { 6 | protected map: Map; 7 | protected view: EditorView; 8 | 9 | public constructor(view: EditorView) { 10 | this.map = new Map(); 11 | this.view = view; 12 | } 13 | 14 | public get(lineNumber: number): TValue | undefined { 15 | return this.map.get(lineNumber); 16 | } 17 | 18 | protected set(lineNumber: number, value: TValue) { 19 | this.map.set(lineNumber, value); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/selections.ts: -------------------------------------------------------------------------------- 1 | import { LineBasedState } from "./linebasedstate"; 2 | import { EditorView, ViewUpdate } from "@codemirror/view"; 3 | import { LinesState, foldsChanged } from "./LinesState"; 4 | import { DrawContext } from "./types"; 5 | import { Config } from "./Config"; 6 | 7 | type Selection = { from: number; to: number; extends: boolean }; 8 | type DrawInfo = { backgroundColor: string }; 9 | 10 | export class SelectionState extends LineBasedState> { 11 | private _drawInfo: DrawInfo | undefined; 12 | private _themeClasses: string; 13 | 14 | public constructor(view: EditorView) { 15 | super(view); 16 | 17 | this.getDrawInfo(); 18 | this._themeClasses = view.dom.classList.value; 19 | } 20 | 21 | private shouldUpdate(update: ViewUpdate) { 22 | // If the minimap is disabled 23 | if (!update.state.facet(Config).enabled) { 24 | return false; 25 | } 26 | 27 | // If the doc changed 28 | if (update.docChanged) { 29 | return true; 30 | } 31 | 32 | // If the selection changed 33 | if (update.selectionSet) { 34 | return true; 35 | } 36 | 37 | // If the theme changed 38 | if (this._themeClasses !== this.view.dom.classList.value) { 39 | return true; 40 | } 41 | 42 | // If the folds changed 43 | if (foldsChanged(update.transactions)) { 44 | return true; 45 | } 46 | 47 | return false; 48 | } 49 | 50 | public update(update: ViewUpdate) { 51 | if (!this.shouldUpdate(update)) { 52 | return; 53 | } 54 | 55 | this.map.clear(); 56 | 57 | /* If class list has changed, clear and recalculate the selection style */ 58 | if (this._themeClasses !== this.view.dom.classList.value) { 59 | this._drawInfo = undefined; 60 | this._themeClasses = this.view.dom.classList.value; 61 | } 62 | 63 | const { ranges } = update.state.selection; 64 | 65 | let selectionIndex = 0; 66 | for (const [index, line] of update.state.field(LinesState).entries()) { 67 | const selections: Array = []; 68 | 69 | let offset = 0; 70 | for (const span of line) { 71 | do { 72 | // We've already processed all selections 73 | if (selectionIndex >= ranges.length) { 74 | continue; 75 | } 76 | 77 | // The next selection begins after this span 78 | if (span.to < ranges[selectionIndex].from) { 79 | continue; 80 | } 81 | 82 | // Ignore 0-length selections 83 | if (ranges[selectionIndex].from === ranges[selectionIndex].to) { 84 | selectionIndex++; 85 | continue; 86 | } 87 | 88 | // Build the selection for the current span 89 | const range = ranges[selectionIndex]; 90 | const selection = { 91 | from: offset + Math.max(span.from, range.from) - span.from, 92 | to: offset + Math.min(span.to, range.to) - span.from, 93 | extends: range.to > span.to, 94 | }; 95 | 96 | const lastSelection = selections.slice(-1)[0]; 97 | if (lastSelection && lastSelection.to === selection.from) { 98 | // The selection in this span may just be a continuation of the 99 | // selection in the previous span 100 | 101 | // Adjust `to` depending on if we're in a folded span 102 | let { to } = selection; 103 | if (span.folded && selection.extends) { 104 | to = selection.from + 1; 105 | } else if (span.folded && !selection.extends) { 106 | to = lastSelection.to; 107 | } 108 | 109 | selections[selections.length - 1] = { 110 | ...lastSelection, 111 | to, 112 | extends: selection.extends, 113 | }; 114 | } else if (!span.folded) { 115 | // It's a new selection; if we're not in a folded span we 116 | // should push it onto the stack 117 | selections.push(selection); 118 | } 119 | 120 | // If the selection doesn't end in this span, break out of the loop 121 | if (selection.extends) { 122 | break; 123 | } 124 | 125 | // Otherwise, move to the next selection 126 | selectionIndex++; 127 | } while ( 128 | selectionIndex < ranges.length && 129 | span.to >= ranges[selectionIndex].from 130 | ); 131 | 132 | offset += span.folded ? 1 : span.to - span.from; 133 | } 134 | 135 | // If we don't have any selections on this line, we don't need to store anything 136 | if (selections.length === 0) { 137 | continue; 138 | } 139 | 140 | // Lines are indexed beginning at 1 instead of 0 141 | const lineNumber = index + 1; 142 | this.map.set(lineNumber, selections); 143 | } 144 | } 145 | 146 | public drawLine(ctx: DrawContext, lineNumber: number) { 147 | let { 148 | context, 149 | lineHeight, 150 | charWidth, 151 | offsetX: startOffsetX, 152 | offsetY, 153 | } = ctx; 154 | const selections = this.get(lineNumber); 155 | if (!selections) { 156 | return; 157 | } 158 | 159 | for (const selection of selections) { 160 | const offsetX = startOffsetX + selection.from * charWidth; 161 | const textWidth = (selection.to - selection.from) * charWidth; 162 | const fullWidth = context.canvas.width - offsetX; 163 | 164 | if (selection.extends) { 165 | // Draw the full width rectangle in the background 166 | context.globalAlpha = 0.65; 167 | context.beginPath(); 168 | context.rect(offsetX, offsetY, fullWidth, lineHeight); 169 | context.fillStyle = this.getDrawInfo().backgroundColor; 170 | context.fill(); 171 | } 172 | 173 | // Draw text selection rectangle in the foreground 174 | context.globalAlpha = 1; 175 | context.beginPath(); 176 | context.rect(offsetX, offsetY, textWidth, lineHeight); 177 | context.fillStyle = this.getDrawInfo().backgroundColor; 178 | context.fill(); 179 | } 180 | } 181 | 182 | private getDrawInfo(): DrawInfo { 183 | if (this._drawInfo) { 184 | return this._drawInfo; 185 | } 186 | 187 | // Create a mock selection 188 | const mockToken = document.createElement("span"); 189 | mockToken.setAttribute("class", "cm-selectionBackground"); 190 | this.view.dom.appendChild(mockToken); 191 | 192 | // Get style information 193 | const style = window.getComputedStyle(mockToken); 194 | const result = { backgroundColor: style.backgroundColor }; 195 | 196 | // Store the result for the next update 197 | this._drawInfo = result; 198 | this.view.dom.removeChild(mockToken); 199 | 200 | return result; 201 | } 202 | } 203 | 204 | export function selections(view: EditorView): SelectionState { 205 | return new SelectionState(view); 206 | } 207 | -------------------------------------------------------------------------------- /src/text.ts: -------------------------------------------------------------------------------- 1 | import { LineBasedState } from "./linebasedstate"; 2 | import { Highlighter, highlightTree } from "@lezer/highlight"; 3 | import { ChangedRange, Tree, TreeFragment } from "@lezer/common"; 4 | import { highlightingFor, language } from "@codemirror/language"; 5 | import { EditorView, ViewUpdate } from "@codemirror/view"; 6 | import { DrawContext } from "./types"; 7 | import { Config, Options, Scale } from "./Config"; 8 | import { LinesState, foldsChanged } from "./LinesState"; 9 | import crelt from "crelt"; 10 | import { ChangeSet, EditorState } from "@codemirror/state"; 11 | 12 | type TagSpan = { text: string; tags: string }; 13 | type FontInfo = { color: string; font: string; lineHeight: number }; 14 | 15 | export class TextState extends LineBasedState> { 16 | private _previousTree: Tree | undefined; 17 | private _displayText: Required["displayText"] | undefined; 18 | private _fontInfoMap: Map = new Map(); 19 | private _themeClasses: Set | undefined; 20 | private _highlightingCallbackId: number | undefined; 21 | 22 | public constructor(view: EditorView) { 23 | super(view); 24 | 25 | this._themeClasses = new Set(view.dom.classList.values()); 26 | 27 | if (view.state.facet(Config).enabled) { 28 | this.updateImpl(view.state); 29 | } 30 | } 31 | 32 | private shouldUpdate(update: ViewUpdate) { 33 | // If the doc changed 34 | if (update.docChanged) { 35 | return true; 36 | } 37 | 38 | // If configuration settings changed 39 | if (update.state.facet(Config) !== update.startState.facet(Config)) { 40 | return true; 41 | } 42 | 43 | // If the theme changed 44 | if (this.themeChanged()) { 45 | return true; 46 | } 47 | 48 | // If the folds changed 49 | if (foldsChanged(update.transactions)) { 50 | return true; 51 | } 52 | 53 | return false; 54 | } 55 | 56 | public update(update: ViewUpdate) { 57 | if (!this.shouldUpdate(update)) { 58 | return; 59 | } 60 | 61 | if (this._highlightingCallbackId) { 62 | typeof window.requestIdleCallback !== "undefined" 63 | ? cancelIdleCallback(this._highlightingCallbackId) 64 | : clearTimeout(this._highlightingCallbackId); 65 | } 66 | 67 | this.updateImpl(update.state, update.changes); 68 | } 69 | 70 | private updateImpl(state: EditorState, changes?: ChangeSet) { 71 | this.map.clear(); 72 | 73 | /* Store display text setting for rendering */ 74 | this._displayText = state.facet(Config).displayText; 75 | 76 | /* If class list has changed, clear and recalculate the font info map */ 77 | if (this.themeChanged()) { 78 | this._fontInfoMap.clear(); 79 | } 80 | 81 | /* Incrementally parse the tree based on previous tree + changes */ 82 | let treeFragments: ReadonlyArray | undefined = undefined; 83 | if (this._previousTree && changes) { 84 | const previousFragments = TreeFragment.addTree(this._previousTree); 85 | 86 | const changedRanges: Array = []; 87 | changes.iterChangedRanges((fromA, toA, fromB, toB) => 88 | changedRanges.push({ fromA, toA, fromB, toB }) 89 | ); 90 | 91 | treeFragments = TreeFragment.applyChanges( 92 | previousFragments, 93 | changedRanges 94 | ); 95 | } 96 | 97 | /* Parse the document into a lezer tree */ 98 | const docToString = state.doc.toString(); 99 | const parser = state.facet(language)?.parser; 100 | const tree = parser ? parser.parse(docToString, treeFragments) : undefined; 101 | this._previousTree = tree; 102 | 103 | /* Highlight the document, and store the text and tags for each line */ 104 | const highlighter: Highlighter = { 105 | style: (tags) => highlightingFor(state, tags), 106 | }; 107 | 108 | let highlights: Array<{ from: number; to: number; tags: string }> = []; 109 | 110 | if (tree) { 111 | /** 112 | * The viewport renders a few extra lines above and below the editor view. To approximate 113 | * the lines visible in the minimap, we multiply the lines in the viewport by the scale multipliers. 114 | * 115 | * Based on the current scroll position, the minimap may show a larger portion of lines above or 116 | * below the lines currently in the editor view. On a long document, when the scroll position is 117 | * near the top of the document, the minimap will show a small number of lines above the lines 118 | * in the editor view, and a large number of lines below the lines in the editor view. 119 | * 120 | * To approximate this ratio, we can use the viewport scroll percentage 121 | * 122 | * ┌─────────────────────┐ 123 | * │ │ 124 | * │ Extra viewport │ 125 | * │ buffer │ 126 | * ├─────────────────────┼───────┐ 127 | * │ │Minimap│ 128 | * │ │Gutter │ 129 | * │ ├───────┤ 130 | * │ Editor View │Scaled │ 131 | * │ │View │ 132 | * │ │Overlay│ 133 | * │ ├───────┤ 134 | * │ │ │ 135 | * │ │ │ 136 | * ├─────────────────────┼───────┘ 137 | * │ │ 138 | * │ Extra viewport │ 139 | * │ buffer │ 140 | * └─────────────────────┘ 141 | * 142 | **/ 143 | 144 | const vpLineTop = state.doc.lineAt(this.view.viewport.from).number; 145 | const vpLineBottom = state.doc.lineAt(this.view.viewport.to).number; 146 | const vpLineCount = vpLineBottom - vpLineTop; 147 | const vpScroll = vpLineTop / (state.doc.lines - vpLineCount); 148 | 149 | const { SizeRatio, PixelMultiplier } = Scale; 150 | const mmLineCount = vpLineCount * SizeRatio * PixelMultiplier; 151 | const mmLineRatio = vpScroll * mmLineCount; 152 | 153 | const mmLineTop = Math.max(1, Math.floor(vpLineTop - mmLineRatio)); 154 | const mmLineBottom = Math.min( 155 | vpLineBottom + Math.floor(mmLineCount - mmLineRatio), 156 | state.doc.lines 157 | ); 158 | 159 | // Highlight the in-view lines synchronously 160 | highlightTree( 161 | tree, 162 | highlighter, 163 | (from, to, tags) => { 164 | highlights.push({ from, to, tags }); 165 | }, 166 | state.doc.line(mmLineTop).from, 167 | state.doc.line(mmLineBottom).to 168 | ); 169 | } 170 | 171 | // Update the map 172 | this.updateMapImpl(state, highlights); 173 | 174 | // Highlight the entire tree in an idle callback 175 | highlights = []; 176 | const highlightingCallback = () => { 177 | if (tree) { 178 | highlightTree(tree, highlighter, (from, to, tags) => { 179 | highlights.push({ from, to, tags }); 180 | }); 181 | this.updateMapImpl(state, highlights); 182 | this._highlightingCallbackId = undefined; 183 | } 184 | }; 185 | this._highlightingCallbackId = 186 | typeof window.requestIdleCallback !== "undefined" 187 | ? requestIdleCallback(highlightingCallback) 188 | : setTimeout(highlightingCallback); 189 | } 190 | 191 | private updateMapImpl( 192 | state: EditorState, 193 | highlights: Array<{ from: number; to: number; tags: string }> 194 | ) { 195 | this.map.clear(); 196 | 197 | const docToString = state.doc.toString(); 198 | const highlightsIterator = highlights.values(); 199 | let highlightPtr = highlightsIterator.next(); 200 | 201 | for (const [index, line] of state.field(LinesState).entries()) { 202 | const spans: Array = []; 203 | 204 | for (const span of line) { 205 | // Skip if it's a 0-length span 206 | if (span.from === span.to) { 207 | continue; 208 | } 209 | 210 | // Append a placeholder for a folded span 211 | if (span.folded) { 212 | spans.push({ text: "…", tags: "" }); 213 | continue; 214 | } 215 | 216 | let position = span.from; 217 | while (!highlightPtr.done && highlightPtr.value.from < span.to) { 218 | const { from, to, tags } = highlightPtr.value; 219 | 220 | // Iterate until our highlight is over the current span 221 | if (to < position) { 222 | highlightPtr = highlightsIterator.next(); 223 | continue; 224 | } 225 | 226 | // Append unstyled text before the highlight begins 227 | if (from > position) { 228 | spans.push({ text: docToString.slice(position, from), tags: "" }); 229 | } 230 | 231 | // A highlight may start before and extend beyond the current span 232 | const start = Math.max(from, span.from); 233 | const end = Math.min(to, span.to); 234 | 235 | // Append the highlighted text 236 | spans.push({ text: docToString.slice(start, end), tags }); 237 | position = end; 238 | 239 | // If the highlight continues beyond this span, break from this loop 240 | if (to > end) { 241 | break; 242 | } 243 | 244 | // Otherwise, move to the next highlight 245 | highlightPtr = highlightsIterator.next(); 246 | } 247 | 248 | // If there are remaining spans that did not get highlighted, append them unstyled 249 | if (position !== span.to) { 250 | spans.push({ 251 | text: docToString.slice(position, span.to), 252 | tags: "", 253 | }); 254 | } 255 | } 256 | 257 | // Lines are indexed beginning at 1 instead of 0 258 | const lineNumber = index + 1; 259 | this.map.set(lineNumber, spans); 260 | } 261 | } 262 | 263 | public measure(context: CanvasRenderingContext2D): { 264 | charWidth: number; 265 | lineHeight: number; 266 | } { 267 | const { color, font, lineHeight } = this.getFontInfo(""); 268 | 269 | context.textBaseline = "ideographic"; 270 | context.fillStyle = color; 271 | context.font = font; 272 | 273 | return { 274 | charWidth: context.measureText("_").width, 275 | lineHeight: lineHeight, 276 | }; 277 | } 278 | 279 | public beforeDraw() { 280 | this._fontInfoMap.clear(); // TODO: Confirm this worked for theme changes or get rid of it because it's slow 281 | } 282 | 283 | public drawLine(ctx: DrawContext, lineNumber: number) { 284 | const line = this.get(lineNumber); 285 | if (!line) { 286 | return; 287 | } 288 | 289 | let { context, charWidth, lineHeight, offsetX, offsetY } = ctx; 290 | 291 | let prevInfo: FontInfo | undefined; 292 | context.textBaseline = "ideographic"; 293 | 294 | for (const span of line) { 295 | const info = this.getFontInfo(span.tags); 296 | 297 | if (!prevInfo || prevInfo.color !== info.color) { 298 | context.fillStyle = info.color; 299 | } 300 | 301 | if (!prevInfo || prevInfo.font !== info.font) { 302 | context.font = info.font; 303 | } 304 | 305 | prevInfo = info; 306 | 307 | lineHeight = Math.max(lineHeight, info.lineHeight); 308 | 309 | switch (this._displayText) { 310 | case "characters": { 311 | // TODO: `fillText` takes up the majority of profiling time in `render` 312 | // Try speeding it up with `drawImage` 313 | // https://stackoverflow.com/questions/8237030/html5-canvas-faster-filltext-vs-drawimage/8237081 314 | 315 | context.fillText(span.text, offsetX, offsetY + lineHeight); 316 | offsetX += span.text.length * charWidth; 317 | break; 318 | } 319 | 320 | case "blocks": { 321 | const nonWhitespace = /\S+/g; 322 | let start: RegExpExecArray | null; 323 | while ((start = nonWhitespace.exec(span.text)) !== null) { 324 | const startX = offsetX + start.index * charWidth; 325 | let width = (nonWhitespace.lastIndex - start.index) * charWidth; 326 | 327 | // Reached the edge of the minimap 328 | if (startX > context.canvas.width) { 329 | break; 330 | } 331 | 332 | // Limit width to edge of minimap 333 | if (startX + width > context.canvas.width) { 334 | width = context.canvas.width - startX; 335 | } 336 | 337 | // Scaled 2px buffer between lines 338 | const yBuffer = 2 / Scale.SizeRatio; 339 | const height = lineHeight - yBuffer; 340 | 341 | context.fillStyle = info.color; 342 | context.globalAlpha = 0.65; // Make the blocks a bit faded 343 | context.beginPath(); 344 | context.rect(startX, offsetY, width, height); 345 | context.fill(); 346 | } 347 | 348 | offsetX += span.text.length * charWidth; 349 | break; 350 | } 351 | } 352 | } 353 | } 354 | 355 | private getFontInfo(tags: string): FontInfo { 356 | const cached = this._fontInfoMap.get(tags); 357 | if (cached) { 358 | return cached; 359 | } 360 | 361 | // Create a mock token wrapped in a cm-line 362 | const mockToken = crelt("span", { class: tags }); 363 | const mockLine = crelt( 364 | "div", 365 | { class: "cm-line", style: "display: none" }, 366 | mockToken 367 | ); 368 | this.view.contentDOM.appendChild(mockLine); 369 | 370 | // Get style information and store it 371 | const style = window.getComputedStyle(mockToken); 372 | const lineHeight = parseFloat(style.lineHeight) / Scale.SizeRatio; 373 | const result = { 374 | color: style.color, 375 | font: `${style.fontStyle} ${style.fontWeight} ${lineHeight}px ${style.fontFamily}`, 376 | lineHeight, 377 | }; 378 | this._fontInfoMap.set(tags, result); 379 | 380 | // Clean up and return 381 | this.view.contentDOM.removeChild(mockLine); 382 | return result; 383 | } 384 | 385 | private themeChanged(): boolean { 386 | const previous = this._themeClasses; 387 | const now = new Set(this.view.dom.classList.values()); 388 | this._themeClasses = now; 389 | 390 | if (!previous) { 391 | return true; 392 | } 393 | 394 | // Ignore certain classes being added/removed 395 | previous.delete("cm-focused"); 396 | now.delete("cm-focused"); 397 | 398 | if (previous.size !== now.size) { 399 | return true; 400 | } 401 | 402 | let containsAll = true; 403 | previous.forEach((theme) => { 404 | if (!now.has(theme)) { 405 | containsAll = false; 406 | } 407 | }); 408 | 409 | return !containsAll; 410 | } 411 | } 412 | 413 | export function text(view: EditorView): TextState { 414 | return new TextState(view); 415 | } 416 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type DrawContext = { 2 | context: CanvasRenderingContext2D; 3 | offsetY: number; 4 | lineHeight: number; 5 | charWidth: number; 6 | offsetX: number; 7 | }; 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | /* Basic Options */ 5 | // "incremental": true, /* Enable incremental compilation */ 6 | "target": "ESNEXT", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */ 7 | "module": "ESNext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 8 | // "lib": [], /* Specify library files to be included in the compilation. */ 9 | // "allowJs": true, /* Allow javascript files to be compiled. */ 10 | // "checkJs": true, /* Report errors in .js files. */ 11 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ 12 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 13 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 14 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 15 | // "outFile": "./", /* Concatenate and emit output to single file. */ 16 | // "outDir": "./", /* Redirect output structure to the directory. */ 17 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 18 | // "composite": true, /* Enable project compilation */ 19 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 20 | // "removeComments": true, /* Do not emit comments to output. */ 21 | "noEmit": true, /* Do not emit outputs. */ 22 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 23 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 24 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 25 | /* Strict Type-Checking Options */ 26 | "strict": true, /* Enable all strict type-checking options. */ 27 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 28 | // "strictNullChecks": true, /* Enable strict null checks. */ 29 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 30 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 31 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 32 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 33 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 34 | /* Additional Checks */ 35 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 36 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 37 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 38 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 39 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 40 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */ 41 | // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ 42 | /* Module Resolution Options */ 43 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 44 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 45 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 46 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 47 | "typeRoots": [ 48 | "./node_modules/@types", 49 | ], /* List of folders to include type definitions from. */ 50 | // "types": [], /* Type declaration files to be included in compilation. */ 51 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 52 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 53 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 54 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 55 | /* Experimental Options */ 56 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 57 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 58 | /* Advanced Options */ 59 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 60 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 61 | }, 62 | "exclude": [ 63 | "node_modules", 64 | ".build" 65 | ] 66 | } --------------------------------------------------------------------------------