1import { closeBrackets } from '@codemirror/autocomplete'; 2import { indentWithTab, toggleLineComment } from '@codemirror/commands'; 3import { javascript, javascriptLanguage } from '@codemirror/lang-javascript'; 4import { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'; 5import { Compartment, EditorState, Prec } from '@codemirror/state'; 6import { 7 drawSelection, 8 EditorView, 9 highlightActiveLine, 10 highlightActiveLineGutter, 11 keymap, 12 lineNumbers, 13} from '@codemirror/view'; 14import { persistentAtom } from '@nanostores/persistent'; 15import { logger, registerControl, repl } from '@strudel/core'; 16import { cleanupDraw, cleanupDrawContext, Drawer } from '@strudel/draw'; 17import { isAutoCompletionEnabled } from './autocomplete.mjs'; 18import { basicSetup } from './basicSetup.mjs'; 19import { evalBlock } from './block_utilities.mjs'; 20import { flash, isFlashEnabled } from './flash.mjs'; 21import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs'; 22import { keybindings } from './keybindings.mjs'; 23import { jumpToCharacter } from './labelJump.mjs'; 24import { getSliderWidgets, sliderPlugin, updateSliderWidgets } from './slider.mjs'; 25import { activateTheme, initTheme, theme } from './themes.mjs'; 26import { isTooltipEnabled } from './tooltip.mjs'; 27import { minimap } from './minimap.mjs'; 28import { getActiveWidgets, updateWidgets, widgetPlugin } from './widget.mjs'; 29 30export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands'; 31 32export const extensions = { 33 isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []), 34 isBracketMatchingEnabled: (on) => (on ? bracketMatching({ brackets: '()[]{}<>' }) : []), 35 isBracketClosingEnabled: (on) => (on ? closeBrackets() : []), 36 isLineNumbersDisplayed: (on) => (on ? lineNumbers() : []), 37 // jevstrudel: the minimap, with the playing lines on it (minimap.mjs) 38 isMinimapEnabled: (on) => (on ? minimap() : []), 39 theme, 40 isAutoCompletionEnabled, 41 isTooltipEnabled, 42 isPatternHighlightingEnabled, 43 isActiveLineHighlighted: (on) => (on ? [highlightActiveLine(), highlightActiveLineGutter()] : []), 44 isFlashEnabled, 45 keybindings, 46 isTabIndentationEnabled: (on) => (on ? keymap.of([indentWithTab]) : []), 47 isMultiCursorEnabled: (on) => 48 on 49 ? [ 50 EditorState.allowMultipleSelections.of(true), 51 EditorView.clickAddsSelectionRange.of((ev) => ev.metaKey || ev.ctrlKey), 52 ] 53 : [], 54}; 55export const compartments = Object.fromEntries(Object.keys(extensions).map((key) => [key, new Compartment()])); 56 57export const defaultSettings = { 58 keybindings: 'codemirror', 59 isBracketMatchingEnabled: false, 60 isBracketClosingEnabled: true, 61 isLineNumbersDisplayed: true, 62 isMinimapEnabled: true, 63 isActiveLineHighlighted: false, 64 isAutoCompletionEnabled: false, 65 isPatternHighlightingEnabled: true, 66 isFlashEnabled: true, 67 isTooltipEnabled: false, 68 isLineWrappingEnabled: false, 69 isTabIndentationEnabled: false, 70 isMultiCursorEnabled: false, 71 isBlockBasedEvalEnabled: false, 72 theme: 'strudelTheme', 73 fontFamily: 'monospace', 74 fontSize: 18, 75}; 76 77export const codemirrorSettings = persistentAtom('codemirror-settings', defaultSettings, { 78 encode: JSON.stringify, 79 decode: JSON.parse, 80}); 81 82// https://codemirror.net/docs/guide/ 83export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, root, mondo, strudelMirror }) { 84 const settings = codemirrorSettings.get(); 85 const initialSettings = Object.keys(compartments).map((key) => 86 compartments[key].of(extensions[key](parseBooleans(settings[key]))), 87 ); 88 89 initTheme(settings.theme); 90 let state = EditorState.create({ 91 doc: initialCode, 92 extensions: [ 93 /* search(), 94 highlightSelectionMatches(), */ 95 ...initialSettings, 96 basicSetup, 97 mondo ? [] : javascript(), 98 javascriptLanguage.data.of({ 99 closeBrackets: { brackets: ['(', '[', '{', "'", '"', '<'] }, 100 bracketMatching: { brackets: ['(', '[', '{', "'", '"', '<'] }, 101 }), 102 sliderPlugin, 103 widgetPlugin, 104 // indentOnInput(), // works without. already brought with javascript 105 // extension? bracketMatching(), // does not do anything 106 syntaxHighlighting(defaultHighlightStyle), 107 EditorView.updateListener.of((v) => onChange(v)), 108 drawSelection({ cursorBlinkRate: 0 }), 109 Prec.highest( 110 keymap.of([ 111 { 112 key: 'Ctrl-Enter', 113 run: () => { 114 // issue with referencing settings, this works more reliably 115 if (strudelMirror?.isBlockBasedEvalEnabled) { 116 evalBlock(strudelMirror); 117 return true; 118 } else { 119 return onEvaluate?.(); 120 } 121 }, 122 }, 123 { 124 key: 'Alt-Enter', 125 run: () => { 126 if (strudelMirror?.isBlockBasedEvalEnabled) { 127 evalBlock(strudelMirror); 128 return true; 129 } else { 130 return onEvaluate?.(); 131 } 132 }, 133 }, 134 { 135 key: 'Ctrl-.', 136 run: () => onStop?.(), 137 }, 138 { 139 key: 'Alt-.', 140 preventDefault: true, 141 run: () => onStop?.(), 142 }, 143 { 144 key: 'Alt-w', 145 run: (view) => jumpToCharacter(view, '$', 1), 146 }, 147 { 148 key: 'Alt-q', 149 run: (view) => jumpToCharacter(view, '$', -1), 150 }, 151 /* { 152 key: 'Ctrl-Shift-.', 153 run: () => (onPanic ? onPanic() : onStop?.()), 154 }, 155 { 156 key: 'Ctrl-Shift-Enter', 157 run: () => (onReEvaluate ? onReEvaluate() : onEvaluate?.()), 158 }, */ 159 ]), 160 ), 161 ], 162 }); 163 164 return new EditorView({ 165 state, 166 parent: root, 167 }); 168} 169 170export class StrudelMirror { 171 constructor(options) { 172 const { 173 root, 174 id, 175 initialCode = '', 176 onDraw, 177 drawContext, 178 drawTime = [0, 0], 179 autodraw, 180 prebake, 181 bgFill = true, 182 solo = true, 183 ...replOptions 184 } = options; 185 this.code = initialCode; 186 this.root = root; 187 this.miniLocations = []; 188 this.widgets = []; 189 this.drawTime = drawTime; 190 this.drawContext = drawContext; 191 this.onDraw = onDraw || this.draw; 192 this.id = id || s4(); 193 this.solo = solo; 194 this.isBlockBasedEvalEnabled = false; // Will be updated via updateSettings() 195 196 this.drawer = new Drawer((haps, time, _, painters) => { 197 const currentFrame = haps.filter((hap) => hap.isActive(time)); 198 this.highlight(currentFrame, time); 199 this.onDraw(haps, time, painters); 200 }, drawTime); 201 202 this.prebaked = prebake(); 203 autodraw && this.drawFirstFrame(); 204 this.repl = repl({ 205 ...replOptions, 206 id, 207 onToggle: (started) => { 208 replOptions?.onToggle?.(started); 209 if (started) { 210 this.drawer.start(this.repl.scheduler); 211 if (this.solo) { 212 // stop other repls when this one is started 213 document.dispatchEvent( 214 new CustomEvent('start-repl', { 215 detail: this.id, 216 }), 217 ); 218 } 219 } else { 220 this.drawer.stop(); 221 updateMiniLocations(this.editor, []); 222 cleanupDraw(true, id); 223 } 224 }, 225 beforeEval: async ({ blockBased } = {}) => { 226 // Only clean up all drawings for full evaluation 227 // Block-based eval should preserve animations (like .scope()) from other blocks 228 if (!blockBased) { 229 cleanupDraw(true, id); 230 } 231 await this.prebaked; 232 await replOptions?.beforeEval?.(); 233 }, 234 afterEval: (options) => { 235 // remember for when highlighting is toggled on 236 this.miniLocations = options.meta?.miniLocations || []; 237 this.widgets = options.meta?.widgets || []; 238 239 const sliders = this.widgets.filter((w) => w.type === 'slider'); 240 const widgets = this.widgets.filter((w) => w.type !== 'slider'); 241 // range-aware update for block-based evaluation 242 const range = options.range && options.range.length >= 2 ? options.range : null; 243 244 updateSliderWidgets(this.editor, sliders, range); 245 updateWidgets(this.editor, widgets, range); 246 updateMiniLocations(this.editor, this.miniLocations, range); 247 replOptions?.afterEval?.(options); 248 // if no painters are set (.onPaint was not called), then we only need 249 // the present moment (for highlighting) 250 const drawTime = options.pattern.getPainters().length ? this.drawTime : [0, 0]; 251 this.drawer.setDrawTime(drawTime); 252 // invalidate drawer after we've set the appropriate drawTime 253 this.drawer.invalidate(this.repl.scheduler); 254 255 // Clean up draw context if a non-inline widget was removed 256 if (options.widgetRemoved) { 257 cleanupDrawContext(id); 258 } 259 }, 260 }); 261 this.cleanupDrawContext = () => cleanupDrawContext(id); 262 this.editor = initEditor({ 263 root, 264 initialCode, 265 onChange: (v) => { 266 if (v.docChanged) { 267 this.code = v.state.doc.toString(); 268 this.repl.setCode?.(this.code); 269 } 270 }, 271 onEvaluate: () => this.evaluate(), 272 onStop: () => this.stop(), 273 mondo: replOptions.mondo, 274 strudelMirror: this, 275 }); 276 277 const cmEditor = this.root.querySelector('.cm-editor'); 278 if (cmEditor) { 279 this.root.style.display = 'block'; 280 if (bgFill) { 281 this.root.style.backgroundColor = 'var(--background)'; 282 } 283 cmEditor.style.backgroundColor = 'transparent'; 284 } 285 const settings = codemirrorSettings.get(); 286 this.setFontSize(settings.fontSize); 287 this.setFontFamily(settings.fontFamily); 288 289 // stop this repl when another repl is started 290 this.onStartRepl = (e) => { 291 if (this.solo && e.detail !== this.id) { 292 this.stop(); 293 } 294 }; 295 document.addEventListener('start-repl', this.onStartRepl); 296 297 // Handle global evaluation requests (e.g., from Vim :w) 298 this.onEvaluateRequest = (e) => { 299 try { 300 if (e.detail.view !== this.editor) { 301 return; // ignore events from other editors 302 } 303 // Evaluate current editor on repl-evaluate 304 logger('[repl] evaluate via event'); 305 this.evaluate(); 306 e?.cancelable && e.preventDefault?.(); 307 } catch (err) { 308 console.error('Error handling repl-evaluate event', err); 309 } 310 }; 311 document.addEventListener('repl-evaluate', this.onEvaluateRequest); 312 document.addEventListener('repl-stop', this.onStopRequest); 313 314 // Toggle comments requested from Vim (gc) 315 this.onToggleComment = (e) => { 316 try { 317 if (e.detail.view !== this.editor) { 318 return; // ignore events from other editors 319 } 320 // Honor selections; toggleLineComment handles both selections and 321 // single line 322 toggleLineComment(this.editor); 323 e?.cancelable && e.preventDefault?.(); 324 } catch (err) { 325 console.error('Error handling repl-toggle-comment event', err); 326 } 327 }; 328 document.addEventListener('repl-toggle-comment', this.onToggleComment); 329 } 330 draw(haps, time, painters) { 331 painters?.forEach((painter) => painter(this.drawContext, time, haps, this.drawTime)); 332 } 333 async drawFirstFrame() { 334 if (!this.onDraw) { 335 return; 336 } 337 // draw first frame instantly 338 await this.prebaked; 339 try { 340 await this.repl.evaluate(this.code, false); 341 this.drawer.invalidate(this.repl.scheduler, -0.001); 342 // draw at -0.001 to avoid haps at 0 to be visualized as active 343 this.onDraw?.(this.drawer.visibleHaps, -0.001, this.drawer.painters); 344 } catch (err) { 345 console.warn('first frame could not be painted'); 346 } 347 } 348 async evaluate(autostart = true) { 349 this.flash(); 350 await this.repl.evaluate(this.code, autostart); 351 } 352 353 async stop() { 354 this.repl.stop(); 355 } 356 357 // Listen for global stop requests (e.g., from Vim :q) 358 onStopRequest = (e) => { 359 try { 360 if (e.detail.view !== this.editor) { 361 return; // ignore events from other editors 362 } 363 this.stop(); 364 e?.cancelable && e.preventDefault?.(); 365 } catch (err) { 366 console.error('Error handling repl-stop event', err); 367 } 368 }; 369 async toggle() { 370 if (this.repl.scheduler.started) { 371 this.repl.stop(); 372 } else { 373 this.evaluate(); 374 } 375 } 376 flash(ms, range) { 377 flash(this.editor, ms, range); 378 } 379 highlight(haps, time) { 380 highlightMiniLocations(this.editor, time, haps); 381 } 382 setFontSize(size) { 383 this.root.style.fontSize = size + 'px'; 384 } 385 setFontFamily(family) { 386 this.root.style.fontFamily = family; 387 const scroller = this.root.querySelector('.cm-scroller'); 388 if (scroller) { 389 scroller.style.fontFamily = family; 390 } 391 } 392 reconfigureExtension(key, value) { 393 if (!extensions[key]) { 394 console.warn(`extension ${key} is not known`); 395 return; 396 } 397 value = parseBooleans(value); 398 const newValue = extensions[key](value, this); 399 this.editor.dispatch({ 400 effects: compartments[key].reconfigure(newValue), 401 }); 402 if (key === 'theme') { 403 activateTheme(value); 404 } 405 } 406 setLineWrappingEnabled(enabled) { 407 this.reconfigureExtension('isLineWrappingEnabled', enabled); 408 } 409 410 setBlockBasedEvalEnabled(enabled) { 411 this.reconfigureExtension('isBlockBasedEvalEnabled', enabled); 412 } 413 setBracketMatchingEnabled(enabled) { 414 this.reconfigureExtension('isBracketMatchingEnabled', enabled); 415 } 416 setLineNumbersDisplayed(enabled) { 417 this.reconfigureExtension('isLineNumbersDisplayed', enabled); 418 } 419 setBracketClosingEnabled(enabled) { 420 this.reconfigureExtension('isBracketClosingEnabled', enabled); 421 } 422 setTheme(theme) { 423 this.reconfigureExtension('theme', theme); 424 } 425 setAutocompletionEnabled(enabled) { 426 this.reconfigureExtension('isAutoCompletionEnabled', enabled); 427 } 428 updateSettings(settings) { 429 this.setFontSize(settings.fontSize); 430 this.setFontFamily(settings.fontFamily); 431 for (let key in extensions) { 432 this.reconfigureExtension(key, settings[key]); 433 } 434 // Update block-based eval setting on the instance 435 if (settings.isBlockBasedEvalEnabled !== undefined) { 436 this.isBlockBasedEvalEnabled = parseBooleans(settings.isBlockBasedEvalEnabled); 437 } 438 const updated = { ...codemirrorSettings.get(), ...settings }; 439 codemirrorSettings.set(updated); 440 } 441 changeSetting(key, value) { 442 if (extensions[key]) { 443 this.reconfigureExtension(key, value); 444 return; 445 } else if (key === 'fontFamily') { 446 this.setFontFamily(value); 447 } else if (key === 'fontSize') { 448 this.setFontSize(value); 449 } 450 } 451 replaceCode(code, from, to) { 452 const changes = { from, to, insert: code }; 453 this.editor.dispatch({ changes }); 454 } 455 insertCode(code, position) { 456 this.replaceCode(code, position, position); 457 } 458 setCode(code) { 459 this.replaceCode(code, 0, this.editor.state.doc.length); 460 } 461 // used for debugging but could serve other purposes 462 getActiveWidgets() { 463 return getActiveWidgets(this.editor); 464 } 465 getSliderWidgets() { 466 return getSliderWidgets(this.editor); 467 } 468 getMiniLocations() { 469 return this.miniLocations; 470 } 471 clear() { 472 this.onStartRepl && document.removeEventListener('start-repl', this.onStartRepl); 473 this.onEvaluateRequest && document.removeEventListener('repl-evaluate', this.onEvaluateRequest); 474 this.onStopRequest && document.removeEventListener('repl-stop', this.onStopRequest); 475 this.onToggleComment && document.removeEventListener('repl-toggle-comment', this.onToggleComment); 476 } 477 getCursorLocation() { 478 return this.editor.state.selection.main.head; 479 } 480 setCursorLocation(col) { 481 return this.editor.dispatch({ selection: { anchor: col } }); 482 } 483 appendCode(code) { 484 const cursor = this.getCursorLocation(); 485 this.setCode(this.code + code); 486 this.setCursorLocation(cursor); 487 } 488} 489 490export function parseBooleans(value) { 491 return { true: true, false: false }[value] ?? value; 492} 493 494// helper function to generate repl ids 495function s4() { 496 return Math.floor((1 + Math.random()) * 0x10000) 497 .toString(16) 498 .substring(1); 499} 500 501/** 502 * Overrides the css of highlighted events. Make sure to use single quotes! 503 * @name markcss 504 * @tag visualization 505 * @example 506 * note("c a f e") 507 * .markcss('text-decoration:underline') 508 */ 509export const markcss = registerControl('markcss');