Merge branch 'main' into documentation
This commit is contained in:
commit
b648961d30
10 changed files with 340 additions and 20 deletions
|
|
@ -27,7 +27,7 @@ import { updateWidgets, widgetPlugin } from './widget.mjs';
|
||||||
|
|
||||||
export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands';
|
export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands';
|
||||||
|
|
||||||
const extensions = {
|
export const extensions = {
|
||||||
isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []),
|
isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []),
|
||||||
isBracketMatchingEnabled: (on) => (on ? bracketMatching({ brackets: '()[]{}<>' }) : []),
|
isBracketMatchingEnabled: (on) => (on ? bracketMatching({ brackets: '()[]{}<>' }) : []),
|
||||||
isBracketClosingEnabled: (on) => (on ? closeBrackets() : []),
|
isBracketClosingEnabled: (on) => (on ? closeBrackets() : []),
|
||||||
|
|
@ -48,7 +48,7 @@ const extensions = {
|
||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
};
|
};
|
||||||
const compartments = Object.fromEntries(Object.keys(extensions).map((key) => [key, new Compartment()]));
|
export const compartments = Object.fromEntries(Object.keys(extensions).map((key) => [key, new Compartment()]));
|
||||||
|
|
||||||
export const defaultSettings = {
|
export const defaultSettings = {
|
||||||
keybindings: 'codemirror',
|
keybindings: 'codemirror',
|
||||||
|
|
@ -411,7 +411,7 @@ export class StrudelMirror {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseBooleans(value) {
|
export function parseBooleans(value) {
|
||||||
return { true: true, false: false }[value] ?? value;
|
return { true: true, false: false }[value] ?? value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,4 @@ export * from './flash.mjs';
|
||||||
export * from './slider.mjs';
|
export * from './slider.mjs';
|
||||||
export * from './themes.mjs';
|
export * from './themes.mjs';
|
||||||
export * from './widget.mjs';
|
export * from './widget.mjs';
|
||||||
|
export { Vim } from './keybindings.mjs';
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,8 @@ const keymaps = {
|
||||||
vscode: vscodeExtension,
|
vscode: vscodeExtension,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { Vim } from '@replit/codemirror-vim';
|
||||||
|
|
||||||
export function keybindings(name) {
|
export function keybindings(name) {
|
||||||
const active = keymaps[name];
|
const active = keymaps[name];
|
||||||
const extensions = active ? [Prec.high(active())] : [];
|
const extensions = active ? [Prec.high(active())] : [];
|
||||||
|
|
|
||||||
|
|
@ -359,7 +359,7 @@ export let analysers = {},
|
||||||
analysersData = {};
|
analysersData = {};
|
||||||
|
|
||||||
export function getAnalyserById(id, fftSize = 1024, smoothingTimeConstant = 0.5) {
|
export function getAnalyserById(id, fftSize = 1024, smoothingTimeConstant = 0.5) {
|
||||||
if (!analysers[id] || analysers[id].audioContext != getAudioContext()) {
|
if (!analysers[id] || analysers[id].context != getAudioContext()) {
|
||||||
// make sure this doesn't happen too often as it piles up garbage
|
// make sure this doesn't happen too often as it piles up garbage
|
||||||
const analyserNode = getAudioContext().createAnalyser();
|
const analyserNode = getAudioContext().createAnalyser();
|
||||||
analyserNode.fftSize = fftSize;
|
analyserNode.fftSize = fftSize;
|
||||||
|
|
|
||||||
17
website/src/components/Dough/Dough.astro
Normal file
17
website/src/components/Dough/Dough.astro
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
---
|
||||||
|
import '../../repl/Repl.css';
|
||||||
|
---
|
||||||
|
|
||||||
|
<div id="code"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { DoughMirror } from './dough-mirror.mjs';
|
||||||
|
const root = document.getElementById('code');
|
||||||
|
const doughmirror = new DoughMirror({ root });
|
||||||
|
globalThis.settings = doughmirror.settings.bind(doughmirror);
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
#code {
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
153
website/src/components/Dough/dough-mirror.mjs
Normal file
153
website/src/components/Dough/dough-mirror.mjs
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
import {
|
||||||
|
initEditor,
|
||||||
|
codemirrorSettings,
|
||||||
|
flash,
|
||||||
|
compartments,
|
||||||
|
extensions,
|
||||||
|
parseBooleans,
|
||||||
|
activateTheme,
|
||||||
|
updateMiniLocations,
|
||||||
|
highlightMiniLocations,
|
||||||
|
} from '@strudel/codemirror';
|
||||||
|
import { evalScope, hash2code, code2hash } from '@strudel/core';
|
||||||
|
import { Framer } from '@strudel/draw';
|
||||||
|
import { persistentAtom } from '@nanostores/persistent';
|
||||||
|
import { DoughRepl } from './dough-repl.mjs';
|
||||||
|
|
||||||
|
let initialCode = '$: note("c a f e")';
|
||||||
|
|
||||||
|
export const code = persistentAtom('vanilla-repl-code', initialCode, {
|
||||||
|
encode: JSON.stringify,
|
||||||
|
decode: JSON.parse,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
const codeParam = window.location.href.split('#')[1] || '';
|
||||||
|
if (codeParam) {
|
||||||
|
const codeFromHash = hash2code(codeParam);
|
||||||
|
code.set(codeFromHash);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('could not init code from url');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DoughMirror {
|
||||||
|
constructor(options) {
|
||||||
|
const { root, initialCode = code.get(), bgFill = true } = options;
|
||||||
|
this.root = root;
|
||||||
|
this.code = initialCode;
|
||||||
|
this.repl = new DoughRepl();
|
||||||
|
this.prebaked = this.prebake();
|
||||||
|
|
||||||
|
// init codemirror
|
||||||
|
this.editor = initEditor({
|
||||||
|
root,
|
||||||
|
initialCode: this.code,
|
||||||
|
onChange: (v) => {
|
||||||
|
if (v.docChanged) {
|
||||||
|
this.code = v.state.doc.toString();
|
||||||
|
code.set(this.code);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onEvaluate: this.evaluate.bind(this),
|
||||||
|
onStop: () => this.stop(),
|
||||||
|
mondo: false,
|
||||||
|
});
|
||||||
|
const settings = codemirrorSettings.get();
|
||||||
|
this.setFontSize(settings.fontSize);
|
||||||
|
this.setFontFamily(settings.fontFamily);
|
||||||
|
|
||||||
|
// init event highlighting
|
||||||
|
this.framer = new Framer(
|
||||||
|
(time) => {
|
||||||
|
const frameHaps = this.repl.processHaps();
|
||||||
|
highlightMiniLocations(this.editor, time, frameHaps);
|
||||||
|
},
|
||||||
|
(err) => console.log('Framer error', err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
prebake() {
|
||||||
|
const modulesLoading = evalScope(import('@strudel/core'), import('@strudel/tonal'), import('@strudel/mini'));
|
||||||
|
return Promise.all([modulesLoading, this.repl.prebake()]);
|
||||||
|
}
|
||||||
|
async evaluate() {
|
||||||
|
this.framer.start();
|
||||||
|
this.flash();
|
||||||
|
await this.prebaked;
|
||||||
|
const { miniLocations } = await this.repl.evaluate(this.code);
|
||||||
|
window.location.hash = '#' + code2hash(this.code);
|
||||||
|
updateMiniLocations(this.editor, miniLocations);
|
||||||
|
}
|
||||||
|
stop() {
|
||||||
|
this.repl.stop();
|
||||||
|
this.framer.stop();
|
||||||
|
highlightMiniLocations(this.editor, 0, []);
|
||||||
|
}
|
||||||
|
// added synonym (compared to StrudelMirror)
|
||||||
|
settings(settings = {}) {
|
||||||
|
this.updateSettings(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the rest is copy pasted from StrudelMirror:
|
||||||
|
|
||||||
|
updateSettings(settings = {}) {
|
||||||
|
settings.fontSize && this.setFontSize(settings.fontSize);
|
||||||
|
settings.fontFamily && this.setFontFamily(settings.fontFamily);
|
||||||
|
for (let key in extensions) {
|
||||||
|
if (key in settings) {
|
||||||
|
this.reconfigureExtension(key, settings[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const updated = { ...codemirrorSettings.get(), ...settings };
|
||||||
|
// console.log(updated);
|
||||||
|
codemirrorSettings.set(updated);
|
||||||
|
}
|
||||||
|
reconfigureExtension(key, value) {
|
||||||
|
if (!extensions[key]) {
|
||||||
|
console.warn(`extension ${key} is not known`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
value = parseBooleans(value);
|
||||||
|
const newValue = extensions[key](value, this);
|
||||||
|
this.editor.dispatch({
|
||||||
|
effects: compartments[key].reconfigure(newValue),
|
||||||
|
});
|
||||||
|
if (key === 'theme') {
|
||||||
|
activateTheme(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flash(ms) {
|
||||||
|
flash(this.editor, ms);
|
||||||
|
}
|
||||||
|
setFontSize(size) {
|
||||||
|
this.root.style.fontSize = size + 'px';
|
||||||
|
}
|
||||||
|
setFontFamily(family) {
|
||||||
|
this.root.style.fontFamily = family;
|
||||||
|
const scroller = this.root.querySelector('.cm-scroller');
|
||||||
|
if (scroller) {
|
||||||
|
scroller.style.fontFamily = family;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setCode(code, offset = 0) {
|
||||||
|
const changes = {
|
||||||
|
from: 0,
|
||||||
|
to: this.editor.state.doc.length + offset,
|
||||||
|
insert: code,
|
||||||
|
};
|
||||||
|
this.editor.dispatch({ changes });
|
||||||
|
}
|
||||||
|
getCursorLocation() {
|
||||||
|
return this.editor.state.selection.main.head;
|
||||||
|
}
|
||||||
|
setCursorLocation(col) {
|
||||||
|
return this.editor.dispatch({ selection: { anchor: col } });
|
||||||
|
}
|
||||||
|
appendCode(code) {
|
||||||
|
const cursor = this.getCursorLocation();
|
||||||
|
this.setCode(this.code + code);
|
||||||
|
this.setCursorLocation(cursor);
|
||||||
|
}
|
||||||
|
}
|
||||||
109
website/src/components/Dough/dough-repl.mjs
Normal file
109
website/src/components/Dough/dough-repl.mjs
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
// import { Dough, doughsamples } from 'dough-synth';
|
||||||
|
import { Dough, doughsamples } from 'https://unpkg.com/dough-synth@0.1.9/dough.js';
|
||||||
|
import { Pattern, noteToMidi, evaluate, stack } from '@strudel/core';
|
||||||
|
// import doughUrl from 'dough-synth?url';
|
||||||
|
import { transpiler } from '@strudel/transpiler';
|
||||||
|
//const doughBaseUrl = doughUrl.split('/').slice(0, -1).join('/') + '/';
|
||||||
|
const doughBaseUrl = 'https://unpkg.com/dough-synth@0.1.9/';
|
||||||
|
|
||||||
|
Object.assign(globalThis, { doughsamples });
|
||||||
|
|
||||||
|
export class DoughRepl {
|
||||||
|
pattern;
|
||||||
|
latency = 0.1;
|
||||||
|
cps = 0.5;
|
||||||
|
origin;
|
||||||
|
t0;
|
||||||
|
lasttime;
|
||||||
|
strudel;
|
||||||
|
q = [];
|
||||||
|
constructor() {
|
||||||
|
this.ready = this.init();
|
||||||
|
}
|
||||||
|
async init() {
|
||||||
|
// init dough immediately, so that it can attach the document click event to initAudio immediately
|
||||||
|
this.dough = new Dough({
|
||||||
|
base: doughBaseUrl,
|
||||||
|
//base: "../", // local dev
|
||||||
|
onTick: ({ t0, t1 }) => {
|
||||||
|
if (!this.pattern) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.origin ??= t0;
|
||||||
|
this.t0 = t0;
|
||||||
|
this.lasttime = performance.now();
|
||||||
|
const a = (t0 - this.origin) * this.cps;
|
||||||
|
const b = (t1 - this.origin) * this.cps;
|
||||||
|
|
||||||
|
const haps = this.pattern.queryArc(a, b).filter((hap) => hap.hasOnset());
|
||||||
|
if (!haps.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
haps.forEach((hap) => {
|
||||||
|
const time = hap.whole.begin.valueOf() / this.cps + this.origin + this.latency;
|
||||||
|
const duration = hap.duration.valueOf() / this.cps;
|
||||||
|
const event = {
|
||||||
|
dough: 'play',
|
||||||
|
...hap.value,
|
||||||
|
time,
|
||||||
|
duration,
|
||||||
|
};
|
||||||
|
if (event.note && typeof event.note === 'string') {
|
||||||
|
event.note = noteToMidi(event.note);
|
||||||
|
}
|
||||||
|
//console.log("event", JSON.stringify(Object.entries(event)));
|
||||||
|
this.dough.evaluate(event);
|
||||||
|
this.q.push({ event, hap });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// miniAllStrings();
|
||||||
|
const setcps = (cps) => (this.cps = cps);
|
||||||
|
const setcpm = (cpm) => setcps(cpm / 60);
|
||||||
|
const replScope = { setcps, setcpm };
|
||||||
|
Object.assign(globalThis, replScope);
|
||||||
|
}
|
||||||
|
async evaluate(code) {
|
||||||
|
await this.ready;
|
||||||
|
let patterns = [];
|
||||||
|
Pattern.prototype.p = function (id) {
|
||||||
|
if (!id.startsWith('_')) {
|
||||||
|
patterns.push(this);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let { meta } = await evaluate(code, transpiler, { addReturn: false, wrapAsync: true, emitWidgets: false });
|
||||||
|
const { miniLocations } = meta;
|
||||||
|
|
||||||
|
this.pattern = stack(...patterns);
|
||||||
|
return { miniLocations, pattern: this.pattern };
|
||||||
|
}
|
||||||
|
stop() {
|
||||||
|
this.pattern = undefined;
|
||||||
|
this.origin = undefined;
|
||||||
|
}
|
||||||
|
prebake() {
|
||||||
|
return Promise.all([
|
||||||
|
// doughsamples('github:eddyflux/crate')
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
// tbd: move this to dough-synth
|
||||||
|
get time() {
|
||||||
|
return this.t0 + (performance.now() - this.lasttime) / 1000 + this.latency;
|
||||||
|
}
|
||||||
|
processHaps() {
|
||||||
|
const currentHaps = [];
|
||||||
|
const time = this.time;
|
||||||
|
this.q = this.q.filter(({ event, hap }) => {
|
||||||
|
const end = event.time + event.duration;
|
||||||
|
const isActive = time >= event.time && time <= end;
|
||||||
|
if (isActive) {
|
||||||
|
currentHaps.push(hap);
|
||||||
|
}
|
||||||
|
return end > time; // delete old events
|
||||||
|
// we do NOT return !isActive, because a frame might miss an event, which would cause a leak
|
||||||
|
});
|
||||||
|
// console.log(this.q.length); // to check for leaks
|
||||||
|
return currentHaps;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
website/src/pages/dough/index.astro
Normal file
15
website/src/pages/dough/index.astro
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
---
|
||||||
|
import HeadCommon from '../../components/HeadCommon.astro';
|
||||||
|
import Dough from '../../components/Dough/Dough.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<html lang="en" class="m-0 dark">
|
||||||
|
<head>
|
||||||
|
<HeadCommon />
|
||||||
|
<title>Strudel Dough REPL</title>
|
||||||
|
</head>
|
||||||
|
<body class="h-app-height bg-background m-0">
|
||||||
|
<Dough />
|
||||||
|
<a rel="me" href="https://social.toplap.org/@strudel" target="_blank" class="hidden">mastodon</a>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -11,9 +11,9 @@ import { JsDoc } from '../../docs/JsDoc';
|
||||||
You can optionally add some music metadata in your Strudel code, by using tags in code comments:
|
You can optionally add some music metadata in your Strudel code, by using tags in code comments:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// @title Hey Hoo
|
// @title My Cool Song
|
||||||
// @by Sam Tagada
|
// @by John Doe
|
||||||
// @license CC BY-NC-SA
|
// @license CC-BY-SA-4.0
|
||||||
```
|
```
|
||||||
|
|
||||||
Like other comments, those are ignored by Strudel, but it can be used by other tools to retrieve some information about the music.
|
Like other comments, those are ignored by Strudel, but it can be used by other tools to retrieve some information about the music.
|
||||||
|
|
@ -24,22 +24,22 @@ You can also use comment blocks:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
/*
|
/*
|
||||||
@title Hey Hoo
|
@title My Cool Song
|
||||||
@by Sam Tagada
|
@by John Doe
|
||||||
@license CC BY-NC-SA
|
@license CC-BY-SA-4.0
|
||||||
*/
|
*/
|
||||||
```
|
```
|
||||||
|
|
||||||
Or define multiple tags in one line:
|
Or define multiple tags in one line:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// @title Hey Hoo @by Sam Tagada @license CC BY-NC-SA
|
// @title My Cool Song @by John Doe @license CC-BY-SA-4.0
|
||||||
```
|
```
|
||||||
|
|
||||||
The `title` tag has an alternative syntax using quotes (must be defined at the very begining):
|
The `title` tag has an alternative syntax using quotes (must be defined at the very begining):
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// "Hey Hoo" @by Sam Tagada
|
// "My Cool Song" @by John Doe
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tags list
|
## Tags list
|
||||||
|
|
@ -48,20 +48,22 @@ Available tags are:
|
||||||
|
|
||||||
- `@title`: music title
|
- `@title`: music title
|
||||||
- `@by`: music author(s), separated by comma, eventually followed with a link in `<>` (ex: `@by John Doe <https://example.com>`)
|
- `@by`: music author(s), separated by comma, eventually followed with a link in `<>` (ex: `@by John Doe <https://example.com>`)
|
||||||
- `@license`: music license(s), e.g. CC BY-NC-SA. Unsure? [Choose a creative commons license here](https://creativecommons.org/choose/)
|
- `@license`: music license(s), separated by comma. Each license should be specified by using the correct identifier in the [https://spdx.org/licenses/](SPDX License List). Example: CC-BY-SA-4.0. Unsure? [Choose a Creative Commons license here](https://creativecommons.org/choose/).
|
||||||
- `@details`: some additional information about the music
|
- `@details`: some additional information about the music
|
||||||
- `@url`: web page(s) related to the music (git repo, soundcloud link, etc.)
|
- `@url`: web page(s) related to the music (git repository, Soundcloud link, etc.)
|
||||||
- `@genre`: music genre(s) (pop, jazz, etc)
|
- `@genre`: music genre(s) (pop, jazz, etc.)
|
||||||
- `@album`: music album name
|
- `@album`: music album name
|
||||||
|
|
||||||
|
Note to tool authors: _Never_ trust that a song has filled those fields with syntactically correct values; make sure your software is robust enough it doesn't break if it encounters bad values
|
||||||
|
|
||||||
## Multiple values
|
## Multiple values
|
||||||
|
|
||||||
Some of them accepts several values, using the comma or new line separator, or duplicating the tag:
|
Some of them accepts several values, using the comma or new line separator, or duplicating the tag:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
/*
|
/*
|
||||||
@by Sam Tagada
|
@by John Doe
|
||||||
Jimmy
|
Jane Doe
|
||||||
@genre pop, jazz
|
@genre pop, jazz
|
||||||
@url https://example.com
|
@url https://example.com
|
||||||
@url https://example.org
|
@url https://example.org
|
||||||
|
|
@ -72,11 +74,11 @@ You can also add optional prefixes and use tags where you want:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
/*
|
/*
|
||||||
song @by Sam Tagada
|
song @by John Doe
|
||||||
samples @by Jimmy
|
samples @by Jane Doe
|
||||||
*/
|
*/
|
||||||
...
|
...
|
||||||
note("a3 c#4 e4 a4") // @by Sandy
|
note("a3 c#4 e4 a4") // @by Sandy Sue
|
||||||
```
|
```
|
||||||
|
|
||||||
## Multiline
|
## Multiline
|
||||||
|
|
|
||||||
|
|
@ -35,3 +35,24 @@ Troubleshooting
|
||||||
|
|
||||||
- If :w logs but evaluation doesn't apply, ensure Vim keybindings are active and try again. You can also use Ctrl+Enter as a fallback.
|
- If :w logs but evaluation doesn't apply, ensure Vim keybindings are active and try again. You can also use Ctrl+Enter as a fallback.
|
||||||
- For :q / gc, ensure focus is inside the editor. If an error occurs, reload the page to reset editor state and try again.
|
- For :q / gc, ensure focus is inside the editor. If an error occurs, reload the page to reset editor state and try again.
|
||||||
|
|
||||||
|
## Adding custom keybindings
|
||||||
|
|
||||||
|
To add custom keybindings the `Vim` object can be used (either from within a pattern or in a prebake script)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Map 'jk' to Escape in normal mode
|
||||||
|
Vim.map('jk', '<Esc>', 'insert');
|
||||||
|
// Map 'U' to :w (Evaluate the current code)
|
||||||
|
Vim.map('U', ':w<CR>', 'normal');
|
||||||
|
// Map 'Q' to :q — Stop/pause playback
|
||||||
|
Vim.map('Q', ':q<CR>', 'normal');
|
||||||
|
// Map 'J' to find next '$' (jump to next label)
|
||||||
|
Vim.map('J', '/\\$<CR>', 'normal');
|
||||||
|
// Map 'K' to find previous '$' (jump to previous label)
|
||||||
|
Vim.map('K', '?\\$<CR>', 'normal');
|
||||||
|
```
|
||||||
|
|
||||||
|
For more information on how to use the `Vim` object see [CodeMirror Vim](https://github.com/replit/codemirror-vim)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue