|
| 1 | +import { EditorState, Compartment } from '@codemirror/state'; |
| 2 | +import { history, defaultKeymap, historyKeymap } from '@codemirror/commands'; |
| 3 | +import { indentUnit, bracketMatching, syntaxHighlighting, defaultHighlightStyle, HighlightStyle } from '@codemirror/language'; |
| 4 | +import { closeBrackets } from '@codemirror/autocomplete'; |
| 5 | +import { lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, highlightActiveLine, keymap, EditorView } from '@codemirror/view'; |
| 6 | + |
| 7 | +import { oneDark } from "@codemirror/theme-one-dark"; |
| 8 | +import { tango } from "./tango.js"; |
| 9 | + |
| 10 | +import { go } from "@codemirror/lang-go"; |
| 11 | + |
| 12 | +// This is a CodeMirror 6 editor. |
| 13 | +// The editor features are wrapped to provide a simpler interface for the |
| 14 | +// simulator. |
| 15 | +export class Editor { |
| 16 | + // Create a new editor, which will be added to the given parent. |
| 17 | + constructor(parent) { |
| 18 | + this.parent = parent; |
| 19 | + this.view = null; |
| 20 | + this.modifyCallback = () => {}; |
| 21 | + this.parentStyles = getComputedStyle(parent); |
| 22 | + |
| 23 | + // Detect dark mode from theme changes. |
| 24 | + matchMedia('(prefers-color-scheme: dark)').onchange = () => { |
| 25 | + this.#setDarkMode(this.#getDarkMode()); |
| 26 | + }; |
| 27 | + |
| 28 | + // Detect dark mode from changes in the <html> attributes (e.g. |
| 29 | + // data-bs-theme="..."). |
| 30 | + new MutationObserver(() => { |
| 31 | + this.#setDarkMode(this.#getDarkMode()); |
| 32 | + }).observe(document.documentElement, {attributes: true}); |
| 33 | + } |
| 34 | + |
| 35 | + // Set (or replace) the callback to call when the text in the editor changed. |
| 36 | + // The changed text can be obtained using the text() method. |
| 37 | + setModifyCallback(callback) { |
| 38 | + this.modifyCallback = callback; |
| 39 | + } |
| 40 | + |
| 41 | + // Return the current text in the editor. |
| 42 | + text() { |
| 43 | + if (!this.view) { |
| 44 | + throw 'editor was not set up yet (need to call setText() first?)'; |
| 45 | + } |
| 46 | + return this.view.state.doc.toString(); |
| 47 | + } |
| 48 | + |
| 49 | + // Replace the text in the editor. This resets the editor state entirely, |
| 50 | + // including the undo history. |
| 51 | + setText(text) { |
| 52 | + const editorState = this.#createEditorState(text, this.modifyCallback); |
| 53 | + |
| 54 | + // Create a new view, or if it already exists, replace the state in the view. |
| 55 | + if (!this.view) { |
| 56 | + this.view = new EditorView({ |
| 57 | + state: editorState, |
| 58 | + parent: this.parent, |
| 59 | + }); |
| 60 | + } else { |
| 61 | + this.view.setState(editorState); |
| 62 | + } |
| 63 | + } |
| 64 | + |
| 65 | + #createEditorState(initialContents) { |
| 66 | + this.darkMode = this.#getDarkMode(); |
| 67 | + |
| 68 | + this.themeConfig = new Compartment(); |
| 69 | + let extensions = [ |
| 70 | + EditorView.updateListener.of(update => { |
| 71 | + if (update.changedRanges.length) { |
| 72 | + this.modifyCallback(); |
| 73 | + } |
| 74 | + }), |
| 75 | + lineNumbers(), |
| 76 | + highlightActiveLineGutter(), |
| 77 | + highlightSpecialChars(), |
| 78 | + history(), |
| 79 | + drawSelection(), |
| 80 | + EditorView.lineWrapping, |
| 81 | + indentUnit.of("\t"), |
| 82 | + bracketMatching(), |
| 83 | + closeBrackets(), |
| 84 | + highlightActiveLine(), |
| 85 | + keymap.of([ |
| 86 | + ...defaultKeymap, |
| 87 | + ...historyKeymap, |
| 88 | + ]), |
| 89 | + go(), |
| 90 | + syntaxHighlighting(defaultHighlightStyle, { fallback: true }), |
| 91 | + this.themeConfig.of(this.#getDarkStyle(this.darkMode)), |
| 92 | + ]; |
| 93 | + |
| 94 | + return EditorState.create({ |
| 95 | + doc: initialContents, |
| 96 | + extensions |
| 97 | + }); |
| 98 | + } |
| 99 | + |
| 100 | + // Get the array of extensions (with the theme) depending on whether we're |
| 101 | + // currently in dark mode or not. |
| 102 | + #getDarkStyle(dark) { |
| 103 | + return dark ? [oneDark] : [tango]; |
| 104 | + } |
| 105 | + |
| 106 | + // Return whether the editor parent node is currently in a dark mode or not. |
| 107 | + #getDarkMode() { |
| 108 | + // Extract the 3 RGB numbers from styles.color. |
| 109 | + let parts = this.parentStyles.color.match(RegExp('\\d+', 'g')); |
| 110 | + // The following is a simplified version of the math found in here to |
| 111 | + // calculate whether styles.color is light or dark. |
| 112 | + // https://stackoverflow.com/questions/596216/formula-to-determine-perceived-brightness-of-rgb-color/56678483#56678483 |
| 113 | + // Approximate linear sRGB. |
| 114 | + let r = Math.pow(parseInt(parts[0]) / 255, 2.2); |
| 115 | + let g = Math.pow(parseInt(parts[1]) / 255, 2.2); |
| 116 | + let b = Math.pow(parseInt(parts[2]) / 255, 2.2); |
| 117 | + // Calculate luminance (in linear sRGB space). |
| 118 | + let luminance = (0.2126*r + 0.7152*g + 0.0722*b); |
| 119 | + // Check whether text luminance is above the "middle grey" threshold of |
| 120 | + // 18.4% (which probably means there's light text on a dark background, aka |
| 121 | + // dark mode). |
| 122 | + let isDark = luminance > 0.184; |
| 123 | + return isDark; |
| 124 | + } |
| 125 | + |
| 126 | + // Update the editor with the given dark mode. |
| 127 | + #setDarkMode(dark) { |
| 128 | + if (dark !== this.darkMode) { |
| 129 | + this.darkMode = dark; |
| 130 | + this.view.dispatch({ |
| 131 | + effects: this.themeConfig.reconfigure(this.#getDarkStyle(dark)), |
| 132 | + }) |
| 133 | + } |
| 134 | + } |
| 135 | +} |
0 commit comments