Skip to content

Commit

Permalink
Use CodeMirror 6 as the code editor
Browse files Browse the repository at this point in the history
Use CodeMirror 6 instead of relying on <textarea>. This provides support
for syntax highlighting, better auto-indentation, line numbers, and the
option to introduce more features (code folding, vi keybindings, etc) in
the future if needed.

I picked CodeMirror 6 mainly because it supports mobile devices. The two
others that I tried (Monaco, Ace) don't work well on mobile. Ace kinda
works but has limitations, Monaco doesn't work at all. In addition, CM6
is relatively small while the others tend to require a lot of
JavaScript.

For more background, see: https://blog.replit.com/code-editors
  • Loading branch information
aykevl committed Jul 6, 2024
1 parent 661d48b commit 685d28a
Show file tree
Hide file tree
Showing 11 changed files with 807 additions and 98 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
release.tar.gz
worker/webworker.bundle.js
/node_modules
/resources/editor.bundle.js
6 changes: 6 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,9 @@ push-docker:
.PHONY: push-gcloud
push-gcloud: release.tar.gz
gcloud builds submit --tag gcr.io/tinygo/playground

resources/editor.bundle.js: editor/editor.js editor/tango.js package.json package-lock.json Makefile
npx rollup editor/editor.js -f es -o resources/editor.bundle.js -p @rollup/plugin-node-resolve

resources/editor.bundle.min.js: resources/editor.bundle.js
npx terser --compress --mangle --output=resources/editor.bundle.min.js resources/editor.bundle.js
25 changes: 10 additions & 15 deletions dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,13 @@ body {
flex-direction: column;
}

#input {
flex-grow: 1;
#editor {
flex: 1 0 0;
overflow: auto;
display: flex;
}
#editor > .cm-editor {
flex: 1 0 0;
}

.schematic {
Expand All @@ -113,12 +118,14 @@ body {
@media screen and (min-width: 800px) {
#middle {
flex-direction: row;
overflow: hidden;
flex-basis: 0;
}
.schematic {
border-top: none;
}
#output {
width: 50vw;
flex: 1 0 0;
}
}

Expand All @@ -143,15 +150,3 @@ header > *:not(h1) {
.edit-symbol {
transform: scaleX(-1);
}

/* textarea */

#input {
font-family: monospace;
font-size: 14px;
-moz-tab-size: 4;
tab-size: 4;
border: none;
padding: 7px;
resize: none;
}
15 changes: 8 additions & 7 deletions dashboard.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Simulator } from './simulator.js';
import { boards } from './boards.js';
import { Editor } from './resources/editor.bundle.min.js';

// This file controls the entire playground window, except for the output part
// on the right that is shared with the VS Code extension.
Expand All @@ -11,6 +12,7 @@ var db = null;
const defaultProjectName = 'console';

let simulator = null;
let editor = null;

// updateBoards updates the dropdown menu. This must be done after loading the
// boards or updating the target selection.
Expand Down Expand Up @@ -129,23 +131,22 @@ async function updateBoards() {
// setProject updates the current project to the new project name.
async function setProject(name) {
if (project && project.created) {
saveProject(project, document.querySelector('#input').value);
saveProject(project, editor.text());
}
project = await loadProject(name);
if (!project) {
// Project not in the database, fall back on something working.
project = await loadProject(defaultProjectName);
}
updateBoards();
let input = document.querySelector('#input');
input.value = project.code;
editor.setText(project.code);

// Load simulator if not already done so (it must only happen once).
if (!simulator) {
let root = document.querySelector('#output');
simulator = new Simulator({
root: root,
input: document.querySelector('#input'),
editor: editor,
firmwareButton: document.querySelector('#btn-flash'),
apiURL: API_URL,
saveState: () => {
Expand All @@ -160,9 +161,6 @@ async function setProject(name) {

// Load the same project on a reload.
localStorage.tinygo_playground_projectName = name;

// Enable the editor (it is diabled on first load).
input.disabled = false;
}

// getProjects returns the complete list of project objects from the projects
Expand Down Expand Up @@ -280,6 +278,9 @@ function loadDB() {

// Initialize the playground.
document.addEventListener('DOMContentLoaded', async function(e) {
// Create the editor.
editor = new Editor(document.getElementById("editor"));

// Start loading everything.
let dbPromise = loadDB();

Expand Down
135 changes: 135 additions & 0 deletions editor/editor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { EditorState, Compartment } from '@codemirror/state';
import { history, defaultKeymap, historyKeymap } from '@codemirror/commands';
import { indentUnit, bracketMatching, syntaxHighlighting, defaultHighlightStyle, HighlightStyle } from '@codemirror/language';
import { closeBrackets } from '@codemirror/autocomplete';
import { lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, highlightActiveLine, keymap, EditorView } from '@codemirror/view';

import { oneDark } from "@codemirror/theme-one-dark";
import { tango } from "./tango.js";

import { go } from "@codemirror/lang-go";

// This is a CodeMirror 6 editor.
// The editor features are wrapped to provide a simpler interface for the
// simulator.
export class Editor {
// Create a new editor, which will be added to the given parent.
constructor(parent) {
this.parent = parent;
this.view = null;
this.modifyCallback = () => {};
this.parentStyles = getComputedStyle(parent);

// Detect dark mode from theme changes.
matchMedia('(prefers-color-scheme: dark)').onchange = () => {
this.#setDarkMode(this.#getDarkMode());
};

// Detect dark mode from changes in the <html> attributes (e.g.
// data-bs-theme="...").
new MutationObserver(() => {
this.#setDarkMode(this.#getDarkMode());
}).observe(document.documentElement, {attributes: true});
}

// Set (or replace) the callback to call when the text in the editor changed.
// The changed text can be obtained using the text() method.
setModifyCallback(callback) {
this.modifyCallback = callback;
}

// Return the current text in the editor.
text() {
if (!this.view) {
throw 'editor was not set up yet (need to call setText() first?)';
}
return this.view.state.doc.toString();
}

// Replace the text in the editor. This resets the editor state entirely,
// including the undo history.
setText(text) {
const editorState = this.#createEditorState(text, this.modifyCallback);

// Create a new view, or if it already exists, replace the state in the view.
if (!this.view) {
this.view = new EditorView({
state: editorState,
parent: this.parent,
});
} else {
this.view.setState(editorState);
}
}

#createEditorState(initialContents) {
this.darkMode = this.#getDarkMode();

this.themeConfig = new Compartment();
let extensions = [
EditorView.updateListener.of(update => {
if (update.changedRanges.length) {
this.modifyCallback();
}
}),
lineNumbers(),
highlightActiveLineGutter(),
highlightSpecialChars(),
history(),
drawSelection(),
EditorView.lineWrapping,
indentUnit.of("\t"),
bracketMatching(),
closeBrackets(),
highlightActiveLine(),
keymap.of([
...defaultKeymap,
...historyKeymap,
]),
go(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
this.themeConfig.of(this.#getDarkStyle(this.darkMode)),
];

return EditorState.create({
doc: initialContents,
extensions
});
}

// Get the array of extensions (with the theme) depending on whether we're
// currently in dark mode or not.
#getDarkStyle(dark) {
return dark ? [oneDark] : [tango];
}

// Return whether the editor parent node is currently in a dark mode or not.
#getDarkMode() {
// Extract the 3 RGB numbers from styles.color.
let parts = this.parentStyles.color.match(RegExp('\\d+', 'g'));
// The following is a simplified version of the math found in here to
// calculate whether styles.color is light or dark.
// https://stackoverflow.com/questions/596216/formula-to-determine-perceived-brightness-of-rgb-color/56678483#56678483
// Approximate linear sRGB.
let r = Math.pow(parseInt(parts[0]) / 255, 2.2);
let g = Math.pow(parseInt(parts[1]) / 255, 2.2);
let b = Math.pow(parseInt(parts[2]) / 255, 2.2);
// Calculate luminance (in linear sRGB space).
let luminance = (0.2126*r + 0.7152*g + 0.0722*b);
// Check whether text luminance is above the "middle grey" threshold of
// 18.4% (which probably means there's light text on a dark background, aka
// dark mode).
let isDark = luminance > 0.184;
return isDark;
}

// Update the editor with the given dark mode.
#setDarkMode(dark) {
if (dark !== this.darkMode) {
this.darkMode = dark;
this.view.dispatch({
effects: this.themeConfig.reconfigure(this.#getDarkStyle(dark)),
})
}
}
}
54 changes: 54 additions & 0 deletions editor/tango.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { syntaxHighlighting, HighlightStyle } from '@codemirror/language';
import { EditorView } from '@codemirror/view';
import {tags as t} from "@lezer/highlight"

// The Tango theme, based on:
// https://github.com/alecthomas/chroma/blob/master/styles/tango.xml
// But this theme in turn came from Pygments:
// https://github.com/pygments/pygments/blob/master/pygments/styles/tango.py
//
// There are a number of differences between the Chroma (and presumably
// Pygments) theme, and the CodeMirror theme here:
//
// - Lezer (the CodeMirror parser) doesn't expose built-in functions (like
// println) and types (like int), so they can't be themed. This is an
// intentional design choice:
// https://github.com/lezer-parser/go/issues/1
// - Lezer doesn't distinguish between '=' and ':='. It also doesn't
// distinguish between the address-taking operand '&' and the field operand
// '.'.
//
// Overall I've tried to keep the semantic meaning of the highlighter the same
// as the original in Chroma (and Pygments):
//
// - I use the bold orange color for operands that feel important: assignments
// (both ':=' and '='), increment/decrement operators ('++', '--'), and
// logic operators ('||', '&&').
// - For all other operands and punctuation, I've picked a black bold style.

export const tangoHighlightStyle = HighlightStyle.define([
{tag: t.keyword,
color: "#204a87", // dark blue (bold)
fontWeight: "bold"},
{tag: t.comment,
color: "#8f5902", // brown-ish orange
fontStyle: "italic"},
{tag: t.string,
color: "#4e9a06"}, // light green
{tag: t.number,
color: "#0000cf", // bright blue
fontWeight: "bold"},
{tag: [t.paren, t.squareBracket, t.brace, t.punctuation, t.operator],
fontWeight: "bold"}, // black
{tag: [t.modifier, t.definitionOperator, t.updateOperator, t.logicOperator],
color: "#ce5c00", // orange
fontWeight: "bold"},
]);

export const tangoTheme = EditorView.theme({
"&": {
backgroundColor: "#f8f8f8",
},
})

export const tango = [tangoTheme, syntaxHighlighting(tangoHighlightStyle)];
3 changes: 2 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<link rel="icon" type="image/png" href="resources/favicon-32x32.png" sizes="32x32"/>
<script type="module" src="dashboard.js"></script>
<script src="resources/bootstrap-5.3.3.bundle.min.js" defer></script>
<link rel="modulepreload" href="resources/editor.bundle.min.js"/>
</head>
<body>
<header>
Expand All @@ -29,7 +30,7 @@ <h1>TinyGo Playground</h1>
<button type="button" id="btn-about" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#aboutModal">About</button>
</header>
<div id="middle">
<textarea id="input" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" disabled>Loading...</textarea>
<div id="editor"></div>
<div id="output" class="simulator">
<div class="schematic-buttons">
<button class="schematic-button-pause schematic-button" title="Pause/resume the simulation">
Expand Down
Loading

0 comments on commit 685d28a

Please sign in to comment.