jevstrudel.git / packages / codemirror / codemirror.mjs
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');