diff --git a/packages/codemirror/Autocomplete.jsx b/packages/codemirror/Autocomplete.jsx new file mode 100644 index 00000000..18f172ee --- /dev/null +++ b/packages/codemirror/Autocomplete.jsx @@ -0,0 +1,88 @@ +import { createRoot } from 'react-dom/client'; +import jsdoc from '../../doc.json'; +// import { javascriptLanguage } from '@codemirror/lang-javascript'; +import { autocompletion } from '@codemirror/autocomplete'; + +const getDocLabel = (doc) => doc.name || doc.longname; +const getInnerText = (html) => { + var div = document.createElement('div'); + div.innerHTML = html; + return div.textContent || div.innerText || ''; +}; + +export function Autocomplete({ doc }) { + return ( +
+

{getDocLabel(doc)}

+
+ +
+ {doc.examples?.map((example, i) => ( +
+
 {
+                console.log('ola!');
+                navigator.clipboard.writeText(example);
+                e.stopPropagation();
+              }}
+            >
+              {example}
+            
+
+ ))} +
+
+ ); +} + +const jsdocCompletions = jsdoc.docs + .filter( + (doc) => + getDocLabel(doc) && + !getDocLabel(doc).startsWith('_') && + !['package'].includes(doc.kind) && + !['superdirtOnly', 'noAutocomplete'].some((tag) => doc.tags?.find((t) => t.originalTitle === tag)), + ) + // https://codemirror.net/docs/ref/#autocomplete.Completion + .map((doc) /*: Completion */ => ({ + label: getDocLabel(doc), + // detail: 'xxx', // An optional short piece of information to show (with a different style) after the label. + info: () => { + const node = document.createElement('div'); + // if Autocomplete is non-interactive, it could also be rendered at build time.. + // .. using renderToStaticMarkup + createRoot(node).render(); + return node; + }, + type: 'function', // https://codemirror.net/docs/ref/#autocomplete.Completion.type + })); + +export const strudelAutocomplete = (context /* : CompletionContext */) => { + let word = context.matchBefore(/\w*/); + if (word.from == word.to && !context.explicit) return null; + return { + from: word.from, + options: jsdocCompletions, + /* options: [ + { label: 'match', type: 'keyword' }, + { label: 'hello', type: 'variable', info: '(World)' }, + { label: 'magic', type: 'text', apply: '⠁⭒*.✩.*⭒⠁', detail: 'macro' }, + ], */ + }; +}; + +export function isAutoCompletionEnabled(on) { + return on + ? [ + autocompletion({ override: [strudelAutocomplete] }), + //javascriptLanguage.data.of({ autocomplete: strudelAutocomplete }), + ] + : []; // autocompletion({ override: [] }) +} diff --git a/packages/codemirror/codemirror.mjs b/packages/codemirror/codemirror.mjs index 2094416e..5ceb9619 100644 --- a/packages/codemirror/codemirror.mjs +++ b/packages/codemirror/codemirror.mjs @@ -1,37 +1,80 @@ -import { defaultKeymap } from '@codemirror/commands'; +import { closeBrackets } from '@codemirror/autocomplete'; +// import { search, highlightSelectionMatches } from '@codemirror/search'; +import { history } from '@codemirror/commands'; import { javascript } from '@codemirror/lang-javascript'; import { defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'; -import { EditorState } from '@codemirror/state'; -import { EditorView, highlightActiveLineGutter, keymap, lineNumbers } from '@codemirror/view'; -import { Drawer, repl } from '@strudel.cycles/core'; -import { flashField, flash } from './flash.mjs'; -import { highlightExtension, highlightMiniLocations } from './highlight.mjs'; -import { oneDark } from './themes/one-dark'; +import { Compartment, EditorState, Prec } from '@codemirror/state'; +import { EditorView, highlightActiveLineGutter, highlightActiveLine, keymap, lineNumbers } from '@codemirror/view'; +import { Pattern, Drawer, repl, cleanupDraw } from '@strudel.cycles/core'; +// import { isAutoCompletionEnabled } from './Autocomplete'; +import { flash, isFlashEnabled } from './flash.mjs'; +import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs'; +import { keybindings } from './keybindings.mjs'; +import { theme } from './themes.mjs'; +import { updateWidgets, sliderPlugin } from './slider.mjs'; + +const extensions = { + isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []), + isLineNumbersDisplayed: (on) => (on ? lineNumbers() : []), + theme, + // isAutoCompletionEnabled, + isPatternHighlightingEnabled, + isActiveLineHighlighted: (on) => (on ? [highlightActiveLine(), highlightActiveLineGutter()] : []), + isFlashEnabled, + keybindings, +}; +const compartments = Object.fromEntries(Object.keys(extensions).map((key) => [key, new Compartment()])); // https://codemirror.net/docs/guide/ -export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, theme = oneDark, root }) { +export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, settings, root }) { + const initialSettings = Object.keys(compartments).map((key) => + compartments[key].of(extensions[key](parseBooleans(settings[key]))), + ); let state = EditorState.create({ doc: initialCode, extensions: [ - theme, + /* search(), + highlightSelectionMatches(), */ + ...initialSettings, javascript(), - lineNumbers(), - highlightExtension, - highlightActiveLineGutter(), + sliderPlugin, + // indentOnInput(), // works without. already brought with javascript extension? + // bracketMatching(), // does not do anything + closeBrackets(), syntaxHighlighting(defaultHighlightStyle), - keymap.of(defaultKeymap), - flashField, + history(), EditorView.updateListener.of((v) => onChange(v)), - keymap.of([ - { - key: 'Ctrl-Enter', - run: () => onEvaluate(), + Prec.highest( + keymap.of([ + { + key: 'Ctrl-Enter', + run: () => onEvaluate?.(), + }, + { + key: 'Alt-Enter', + run: () => onEvaluate?.(), + }, + { + key: 'Ctrl-.', + run: () => onStop?.(), + }, + { + key: 'Alt-.', + run: (_, e) => { + e.preventDefault(); + onStop?.(); + }, + }, + /* { + key: 'Ctrl-Shift-.', + run: () => (onPanic ? onPanic() : onStop?.()), }, { - key: 'Ctrl-.', - run: () => onStop(), - }, - ]), + key: 'Ctrl-Shift-Enter', + run: () => (onReEvaluate ? onReEvaluate() : onEvaluate?.()), + }, */ + ]), + ), ], }); @@ -43,71 +86,168 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, the export class StrudelMirror { constructor(options) { - const { root, initialCode = '', onDraw, drawTime = [-2, 2], prebake, ...replOptions } = options; + const { root, initialCode = '', onDraw, drawTime = [-2, 2], prebake, settings, ...replOptions } = options; this.code = initialCode; + this.root = root; + this.miniLocations = []; + this.widgets = []; + this.painters = []; + this.onDraw = onDraw; + const self = this; this.drawer = new Drawer((haps, time) => { const currentFrame = haps.filter((hap) => time >= hap.whole.begin && time <= hap.endClipped); this.highlight(currentFrame, time); - onDraw?.(haps, time, currentFrame); + this.onDraw?.(haps, time, currentFrame, this.painters); }, drawTime); - const prebaked = prebake(); - prebaked.then(async () => { - if (!onDraw) { - return; - } - const { scheduler, evaluate } = await this.repl; - // draw first frame instantly - prebaked.then(async () => { - await evaluate(this.code, false); - this.drawer.invalidate(scheduler); - onDraw?.(this.drawer.visibleHaps, 0, []); - }); - }); + // this approach might not work with multiple repls on screen.. + Pattern.prototype.onPaint = function (onPaint) { + self.painters.push(onPaint); + return this; + }; + + this.prebaked = prebake(); + // this.drawFirstFrame(); this.repl = repl({ ...replOptions, - onToggle: async (started) => { + onToggle: (started) => { replOptions?.onToggle?.(started); - const { scheduler } = await this.repl; if (started) { - this.drawer.start(scheduler); + this.drawer.start(this.repl.scheduler); } else { this.drawer.stop(); + updateMiniLocations(this.editor, []); + cleanupDraw(false); } }, beforeEval: async () => { - await prebaked; + cleanupDraw(); + this.painters = []; + await this.prebaked; + await replOptions?.beforeEval?.(); }, afterEval: (options) => { + // remember for when highlighting is toggled on + this.miniLocations = options.meta?.miniLocations; + this.widgets = options.meta?.widgets; + updateWidgets(this.editor, this.widgets); + updateMiniLocations(this.editor, this.miniLocations); replOptions?.afterEval?.(options); this.drawer.invalidate(); }, }); this.editor = initEditor({ root, + settings, initialCode, onChange: (v) => { - this.code = v.state.doc.toString(); + if (v.docChanged) { + this.code = v.state.doc.toString(); + // TODO: repl is still untouched to make sure the old Repl.jsx stays untouched.. + // this.repl.setCode(this.code); + } }, onEvaluate: () => this.evaluate(), onStop: () => this.stop(), }); + const cmEditor = this.root.querySelector('.cm-editor'); + if (cmEditor) { + this.root.style.backgroundColor = 'var(--background)'; + cmEditor.style.backgroundColor = 'transparent'; + } + } + async drawFirstFrame() { + if (!this.onDraw) { + return; + } + // draw first frame instantly + await this.prebaked; + try { + await this.repl.evaluate(this.code, false); + this.drawer.invalidate(this.repl.scheduler); + this.onDraw?.(this.drawer.visibleHaps, 0, []); + } catch (err) { + console.warn('first frame could not be painted'); + } } async evaluate() { - const { evaluate } = await this.repl; this.flash(); - await evaluate(this.code); + await this.repl.evaluate(this.code); } async stop() { - const { scheduler } = await this.repl; - scheduler.stop(); + this.repl.scheduler.stop(); + } + async toggle() { + if (this.repl.scheduler.started) { + this.repl.scheduler.stop(); + } else { + this.evaluate(); + } } flash(ms) { flash(this.editor, ms); } highlight(haps, time) { - highlightMiniLocations(this.editor.view, time, haps); + highlightMiniLocations(this.editor, time, haps); + } + 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; + } + } + 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), + }); + } + setLineWrappingEnabled(enabled) { + this.reconfigureExtension('isLineWrappingEnabled', enabled); + } + setLineNumbersDisplayed(enabled) { + this.reconfigureExtension('isLineNumbersDisplayed', enabled); + } + setTheme(theme) { + this.reconfigureExtension('theme', theme); + } + setAutocompletionEnabled(enabled) { + this.reconfigureExtension('isAutoCompletionEnabled', enabled); + } + updateSettings(settings) { + this.setFontSize(settings.fontSize); + this.setFontFamily(settings.fontFamily); + for (let key in extensions) { + this.reconfigureExtension(key, settings[key]); + } + } + changeSetting(key, value) { + if (extensions[key]) { + this.reconfigureExtension(key, value); + return; + } else if (key === 'fontFamily') { + this.setFontFamily(value); + } else if (key === 'fontSize') { + this.setFontSize(value); + } + } + setCode(code) { + const changes = { from: 0, to: this.editor.state.doc.length, insert: code }; + this.editor.dispatch({ changes }); } } + +function parseBooleans(value) { + return { true: true, false: false }[value] ?? value; +} diff --git a/packages/codemirror/examples/strudelmirror/.gitignore b/packages/codemirror/examples/strudelmirror/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/packages/codemirror/examples/strudelmirror/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/packages/codemirror/examples/strudelmirror/index.html b/packages/codemirror/examples/strudelmirror/index.html new file mode 100644 index 00000000..0e1d43ce --- /dev/null +++ b/packages/codemirror/examples/strudelmirror/index.html @@ -0,0 +1,87 @@ + + + + + + + StrudelMirror Example + + +
+
+
+ + +
+ +
+ +
+ +
+ +
+ + + +
+
+
+ + + diff --git a/packages/codemirror/examples/strudelmirror/main.js b/packages/codemirror/examples/strudelmirror/main.js new file mode 100644 index 00000000..676f4b97 --- /dev/null +++ b/packages/codemirror/examples/strudelmirror/main.js @@ -0,0 +1,199 @@ +import { logger, getDrawContext, silence, controls, evalScope, hash2code, code2hash } from '@strudel.cycles/core'; +import { StrudelMirror } from '@strudel/codemirror'; +import { transpiler } from '@strudel.cycles/transpiler'; +import { + getAudioContext, + webaudioOutput, + registerSynthSounds, + registerZZFXSounds, + samples, +} from '@strudel.cycles/webaudio'; +import './style.css'; + +let editor; +const initialSettings = { + keybindings: 'codemirror', + isLineNumbersDisplayed: true, + isActiveLineHighlighted: true, + isAutoCompletionEnabled: false, + isPatternHighlightingEnabled: true, + isFlashEnabled: true, + isTooltipEnabled: false, + isLineWrappingEnabled: false, + theme: 'teletext', + fontFamily: 'monospace', + fontSize: 18, +}; + +async function run() { + const container = document.getElementById('code'); + if (!container) { + console.warn('could not init: no container found'); + return; + } + + const drawContext = getDrawContext(); + const drawTime = [-2, 2]; + editor = new StrudelMirror({ + defaultOutput: webaudioOutput, + getTime: () => getAudioContext().currentTime, + transpiler, + root: container, + initialCode: '// LOADING', + pattern: silence, + settings: initialSettings, + drawTime, + onDraw: (haps, time, frame, painters) => { + painters.length && drawContext.clearRect(0, 0, drawContext.canvas.width * 2, drawContext.canvas.height * 2); + painters?.forEach((painter) => { + // ctx time haps drawTime paintOptions + painter(drawContext, time, haps, drawTime, { clear: false }); + }); + }, + prebake: async () => { + // populate scope / lazy load modules + const modulesLoading = evalScope( + import('@strudel.cycles/core'), + import('@strudel.cycles/tonal'), + import('@strudel.cycles/mini'), + // import('@strudel.cycles/xen'), + import('@strudel.cycles/webaudio'), + import('@strudel/codemirror'), + /* import('@strudel/hydra'), */ + // import('@strudel.cycles/serial'), + /* import('@strudel.cycles/soundfonts'), */ + // import('@strudel.cycles/csound'), + /* import('@strudel.cycles/midi'), */ + // import('@strudel.cycles/osc'), + controls, // sadly, this cannot be exported from core directly (yet) + ); + // load samples + const ds = 'https://raw.githubusercontent.com/felixroos/dough-samples/main/'; + await Promise.all([ + modulesLoading, + registerSynthSounds(), + registerZZFXSounds(), + samples(`${ds}/tidal-drum-machines.json`), + samples(`${ds}/piano.json`), + samples(`${ds}/Dirt-Samples.json`), + samples(`${ds}/EmuSP12.json`), + samples(`${ds}/vcsl.json`), + ]); + }, + afterEval: ({ code }) => { + window.location.hash = '#' + code2hash(code); + }, + }); + + // init settings + editor.updateSettings(initialSettings); + + logger(`Welcome to Strudel! Click into the editor and then hit ctrl+enter to run the code!`, 'highlight'); + const codeParam = window.location.href.split('#')[1] || ''; + + const initialCode = codeParam + ? hash2code(codeParam) + : `// @date 23-11-30 +// "teigrührgerät" @by froos + +stack( + stack( + s("bd(<3!3 5>,6)/2").bank('RolandTR707') + , + s("~ sd:<0 1>").bank('RolandTR707').room("<0 .5>") + .lastOf(8, x=>x.segment("12").end(.2).gain(isaw)) + , + s("[tb ~ tb]").bank('RolandTR707') + .clip(0).release(.08).room(.2) + ).off(-1/6, x=>x.speed(.7).gain(.2).degrade()) + , + stack( + note(",6) ~!2 [f1?]*2>") + .s("sawtooth").lpf(perlin.range(400,1000)) + .lpa(.1).lpenv(-3).room(.2) + .lpq(8).noise(.2) + .add(note("0,.1")) + , + chord("<~ Gm9 ~!2>") + .dict('ireal').voicing() + .s("sawtooth").vib("2:.1") + .lpf(1000).lpa(.1).lpenv(-4) + .room(.5) + , + n(run(3)).chord("/8") + .dict('ireal-ext') + .off(1/2, add(n(4))) + .voicing() + .clip(.1).release(.05) + .s("sine").jux(rev) + .sometimesBy(sine.slow(16), add(note(12))) + .room(.75) + .lpf(sine.range(200,2000).slow(16)) + .gain(saw.slow(4).div(2)) + ).add(note(perlin.range(0,.5))) +)`; + + editor.setCode(initialCode); // simpler alternative to above init + + // settingsMap.listen((settings, key) => editor.changeSetting(key, settings[key])); + onEvent('strudel-toggle-play', () => editor.toggle()); +} + +run(); + +function onEvent(key, callback) { + const listener = (e) => { + if (e.data === key) { + callback(); + } + }; + window.addEventListener('message', listener); + return () => window.removeEventListener('message', listener); +} + +// settings form +function getInput(form, name) { + return form.querySelector(`input[name=${name}]`) || form.querySelector(`select[name=${name}]`); +} +function getFormValues(form, initial) { + const entries = Object.entries(initial).map(([key, initialValue]) => { + const input = getInput(form, key); + if (!input) { + return [key, initialValue]; // fallback + } + if (input.type === 'checkbox') { + return [key, input.checked]; + } + if (input.type === 'number') { + return [key, Number(input.value)]; + } + if (input.tagName === 'SELECT') { + return [key, input.value]; + } + return [key, input.value]; + }); + return Object.fromEntries(entries); +} +function setFormValues(form, values) { + Object.entries(values).forEach(([key, value]) => { + const input = getInput(form, key); + if (!input) { + return; + } + if (input.type === 'checkbox') { + input.checked = !!value; + } else if (input.type === 'number') { + input.value = value; + } else if (input.tagName) { + input.value = value; + } + }); +} + +const form = document.querySelector('form[name=settings]'); +setFormValues(form, initialSettings); +form.addEventListener('change', () => { + const values = getFormValues(form, initialSettings); + // console.log('values', values); + editor.updateSettings(values); +}); diff --git a/packages/codemirror/examples/strudelmirror/package.json b/packages/codemirror/examples/strudelmirror/package.json new file mode 100644 index 00000000..5c946bff --- /dev/null +++ b/packages/codemirror/examples/strudelmirror/package.json @@ -0,0 +1,29 @@ +{ + "name": "strudelmirror", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "vite": "^5.0.8" + }, + "dependencies": { + "@strudel/codemirror": "workspace:*", + "@strudel.cycles/core":"workspace:*", + "@strudel.cycles/transpiler":"workspace:*", + "@strudel.cycles/tonal":"workspace:*", + "@strudel.cycles/mini":"workspace:*", + "@strudel.cycles/xen":"workspace:*", + "@strudel.cycles/webaudio":"workspace:*", + "@strudel/hydra":"workspace:*", + "@strudel.cycles/serial":"workspace:*", + "@strudel.cycles/soundfonts":"workspace:*", + "@strudel.cycles/csound":"workspace:*", + "@strudel.cycles/midi":"workspace:*", + "@strudel.cycles/osc":"workspace:*" + } +} diff --git a/packages/codemirror/examples/strudelmirror/style.css b/packages/codemirror/examples/strudelmirror/style.css new file mode 100644 index 00000000..fabc795c --- /dev/null +++ b/packages/codemirror/examples/strudelmirror/style.css @@ -0,0 +1,33 @@ +:root { + --foreground: white; +} + +body, +input { + font-family: monospace; + background: black; + color: white; +} + +html, +body, +#code, +.cm-editor, +.cm-scroller { + padding: 0; + margin: 0; + height: 100%; +} + +.settings { + position: fixed; + right: 0; + top: 0; + z-index: 1000; + display: flex-col; + padding: 10px; +} + +.settings > form > * + * { + margin-top: 10px; +} diff --git a/packages/codemirror/flash.mjs b/packages/codemirror/flash.mjs index 9bc5c593..6b37038f 100644 --- a/packages/codemirror/flash.mjs +++ b/packages/codemirror/flash.mjs @@ -33,3 +33,5 @@ export const flash = (view, ms = 200) => { view.dispatch({ effects: setFlash.of(false) }); }, ms); }; + +export const isFlashEnabled = (on) => (on ? flashField : []); diff --git a/packages/codemirror/highlight.mjs b/packages/codemirror/highlight.mjs index 317c5fdf..79724f8f 100644 --- a/packages/codemirror/highlight.mjs +++ b/packages/codemirror/highlight.mjs @@ -124,3 +124,12 @@ const miniLocationHighlights = EditorView.decorations.compute([miniLocations, vi }); export const highlightExtension = [miniLocations, visibleMiniLocations, miniLocationHighlights]; + +export const isPatternHighlightingEnabled = (on, config) => { + on && + config && + setTimeout(() => { + updateMiniLocations(config.editor, config.miniLocations); + }, 100); + return on ? highlightExtension : []; +}; diff --git a/packages/codemirror/index.mjs b/packages/codemirror/index.mjs index c847c32c..8f2d1630 100644 --- a/packages/codemirror/index.mjs +++ b/packages/codemirror/index.mjs @@ -2,3 +2,4 @@ export * from './codemirror.mjs'; export * from './highlight.mjs'; export * from './flash.mjs'; export * from './slider.mjs'; +export * from './themes.mjs'; diff --git a/packages/codemirror/keybindings.mjs b/packages/codemirror/keybindings.mjs new file mode 100644 index 00000000..6fe00eda --- /dev/null +++ b/packages/codemirror/keybindings.mjs @@ -0,0 +1,31 @@ +import { Prec } from '@codemirror/state'; +import { keymap, ViewPlugin } from '@codemirror/view'; +// import { searchKeymap } from '@codemirror/search'; +import { emacs } from '@replit/codemirror-emacs'; +import { vim } from '@replit/codemirror-vim'; +import { vscodeKeymap } from '@replit/codemirror-vscode-keymap'; +import { defaultKeymap, historyKeymap } from '@codemirror/commands'; + +const vscodePlugin = ViewPlugin.fromClass( + class { + constructor() {} + }, + { + provide: () => { + return Prec.highest(keymap.of([...vscodeKeymap])); + }, + }, +); +const vscodeExtension = (options) => [vscodePlugin].concat(options ?? []); + +const keymaps = { + vim, + emacs, + vscode: vscodeExtension, +}; + +export function keybindings(name) { + const active = keymaps[name]; + return [keymap.of(defaultKeymap), keymap.of(historyKeymap), active ? active() : []]; + // keymap.of(searchKeymap), +} diff --git a/packages/codemirror/package.json b/packages/codemirror/package.json index 4e443648..a309efa2 100644 --- a/packages/codemirror/package.json +++ b/packages/codemirror/package.json @@ -33,13 +33,21 @@ }, "homepage": "https://github.com/tidalcycles/strudel#readme", "dependencies": { + "@codemirror/autocomplete": "^6.6.0", "@codemirror/commands": "^6.2.4", "@codemirror/lang-javascript": "^6.1.7", "@codemirror/language": "^6.6.0", + "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.2.0", "@codemirror/view": "^6.10.0", "@lezer/highlight": "^1.1.4", - "@strudel.cycles/core": "workspace:*" + "@replit/codemirror-emacs": "^6.0.1", + "@replit/codemirror-vim": "^6.0.14", + "@replit/codemirror-vscode-keymap": "^6.0.2", + "@strudel.cycles/core": "workspace:*", + "@uiw/codemirror-themes": "^4.19.16", + "@uiw/codemirror-themes-all": "^4.19.16", + "react-dom": "^18.2.0" }, "devDependencies": { "vite": "^4.3.3" diff --git a/packages/codemirror/themes.mjs b/packages/codemirror/themes.mjs new file mode 100644 index 00000000..71fb7642 --- /dev/null +++ b/packages/codemirror/themes.mjs @@ -0,0 +1,521 @@ +import { + abcdef, + androidstudio, + atomone, + aura, + bespin, + darcula, + dracula, + duotoneDark, + eclipse, + githubDark, + gruvboxDark, + materialDark, + nord, + okaidia, + solarizedDark, + sublime, + tokyoNight, + tokyoNightStorm, + vscodeDark, + xcodeDark, + bbedit, + duotoneLight, + githubLight, + gruvboxLight, + materialLight, + noctisLilac, + solarizedLight, + tokyoNightDay, + xcodeLight, +} from '@uiw/codemirror-themes-all'; + +import strudelTheme from './themes/strudel-theme'; +import bluescreen, { settings as bluescreenSettings } from './themes/bluescreen'; +import blackscreen, { settings as blackscreenSettings } from './themes/blackscreen'; +import whitescreen, { settings as whitescreenSettings } from './themes/whitescreen'; +import teletext, { settings as teletextSettings } from './themes/teletext'; +import algoboy, { settings as algoboySettings } from './themes/algoboy'; +import terminal, { settings as terminalSettings } from './themes/terminal'; + +export const themes = { + strudelTheme, + bluescreen, + blackscreen, + whitescreen, + teletext, + algoboy, + terminal, + abcdef, + androidstudio, + atomone, + aura, + bespin, + darcula, + dracula, + duotoneDark, + eclipse, + githubDark, + gruvboxDark, + materialDark, + nord, + okaidia, + solarizedDark, + sublime, + tokyoNight, + tokyoNightStorm, + vscodeDark, + xcodeDark, + bbedit, + duotoneLight, + githubLight, + gruvboxLight, + materialLight, + noctisLilac, + solarizedLight, + tokyoNightDay, + xcodeLight, +}; + +// lineBackground is background with 50% opacity, to make sure the selection below is visible + +export const settings = { + strudelTheme: { + background: '#222', + lineBackground: '#22222299', + foreground: '#fff', + // foreground: '#75baff', + caret: '#ffcc00', + selection: 'rgba(128, 203, 196, 0.5)', + selectionMatch: '#036dd626', + // lineHighlight: '#8a91991a', // original + lineHighlight: '#00000050', + gutterBackground: 'transparent', + // gutterForeground: '#8a919966', + gutterForeground: '#8a919966', + }, + bluescreen: bluescreenSettings, + blackscreen: blackscreenSettings, + whitescreen: whitescreenSettings, + teletext: teletextSettings, + algoboy: algoboySettings, + terminal: terminalSettings, + abcdef: { + background: '#0f0f0f', + lineBackground: '#0f0f0f99', + foreground: '#defdef', + caret: '#00FF00', + selection: '#515151', + selectionMatch: '#515151', + gutterBackground: '#555', + gutterForeground: '#FFFFFF', + lineHighlight: '#314151', + }, + androidstudio: { + background: '#282b2e', + lineBackground: '#282b2e99', + foreground: '#a9b7c6', + caret: '#00FF00', + selection: '#343739', + selectionMatch: '#343739', + lineHighlight: '#343739', + }, + atomone: { + background: '#272C35', + lineBackground: '#272C3599', + foreground: '#9d9b97', + caret: '#797977', + selection: '#ffffff30', + selectionMatch: '#2B323D', + gutterBackground: '#272C35', + gutterForeground: '#465063', + gutterBorder: 'transparent', + lineHighlight: '#2B323D', + }, + aura: { + background: '#21202e', + lineBackground: '#21202e99', + foreground: '#edecee', + caret: '#a277ff', + selection: '#3d375e7f', + selectionMatch: '#3d375e7f', + gutterBackground: '#21202e', + gutterForeground: '#edecee', + gutterBorder: 'transparent', + lineHighlight: '#a394f033', + }, + bbedit: { + light: true, + background: '#FFFFFF', + lineBackground: '#FFFFFF99', + foreground: '#000000', + caret: '#FBAC52', + selection: '#FFD420', + selectionMatch: '#FFD420', + gutterBackground: '#f5f5f5', + gutterForeground: '#4D4D4C', + gutterBorder: 'transparent', + lineHighlight: '#00000012', + }, + bespin: { + background: '#28211c', + lineBackground: '#28211c99', + foreground: '#9d9b97', + caret: '#797977', + selection: '#36312e', + selectionMatch: '#4f382b', + gutterBackground: '#28211c', + gutterForeground: '#666666', + lineHighlight: 'rgba(255, 255, 255, 0.1)', + }, + darcula: { + background: '#2B2B2B', + lineBackground: '#2B2B2B99', + foreground: '#f8f8f2', + caret: '#FFFFFF', + selection: 'rgba(255, 255, 255, 0.1)', + selectionMatch: 'rgba(255, 255, 255, 0.2)', + gutterBackground: 'rgba(255, 255, 255, 0.1)', + gutterForeground: '#999', + gutterBorder: 'transparent', + lineHighlight: 'rgba(255, 255, 255, 0.1)', + }, + dracula: { + background: '#282a36', + lineBackground: '#282a3699', + foreground: '#f8f8f2', + caret: '#f8f8f0', + selection: 'rgba(255, 255, 255, 0.1)', + selectionMatch: 'rgba(255, 255, 255, 0.2)', + gutterBackground: '#282a36', + gutterForeground: '#6D8A88', + gutterBorder: 'transparent', + lineHighlight: 'rgba(255, 255, 255, 0.1)', + }, + duotoneLight: { + light: true, + background: '#faf8f5', + lineBackground: '#faf8f599', + foreground: '#b29762', + caret: '#93abdc', + selection: '#e3dcce', + selectionMatch: '#e3dcce', + gutterBackground: '#faf8f5', + gutterForeground: '#cdc4b1', + gutterBorder: 'transparent', + lineHighlight: '#EFEFEF', + }, + duotoneDark: { + background: '#2a2734', + lineBackground: '#2a273499', + foreground: '#6c6783', + caret: '#ffad5c', + selection: 'rgba(255, 255, 255, 0.1)', + gutterBackground: '#2a2734', + gutterForeground: '#545167', + lineHighlight: '#36334280', + }, + eclipse: { + light: true, + background: '#fff', + lineBackground: '#ffffff99', + foreground: '#000', + caret: '#FFFFFF', + selection: '#d7d4f0', + selectionMatch: '#d7d4f0', + gutterBackground: '#f7f7f7', + gutterForeground: '#999', + lineHighlight: '#e8f2ff', + gutterBorder: 'transparent', + }, + githubLight: { + light: true, + background: '#fff', + lineBackground: '#ffffff99', + foreground: '#24292e', + selection: '#BBDFFF', + selectionMatch: '#BBDFFF', + gutterBackground: '#fff', + gutterForeground: '#6e7781', + }, + githubDark: { + background: '#0d1117', + lineBackground: '#0d111799', + foreground: '#c9d1d9', + caret: '#c9d1d9', + selection: '#003d73', + selectionMatch: '#003d73', + lineHighlight: '#36334280', + }, + gruvboxDark: { + background: '#282828', + lineBackground: '#28282899', + foreground: '#ebdbb2', + caret: '#ebdbb2', + selection: '#bdae93', + selectionMatch: '#bdae93', + lineHighlight: '#3c3836', + gutterBackground: '#282828', + gutterForeground: '#7c6f64', + }, + gruvboxLight: { + light: true, + background: '#fbf1c7', + lineBackground: '#fbf1c799', + foreground: '#3c3836', + caret: '#af3a03', + selection: '#ebdbb2', + selectionMatch: '#bdae93', + lineHighlight: '#ebdbb2', + gutterBackground: '#ebdbb2', + gutterForeground: '#665c54', + gutterBorder: 'transparent', + }, + materialDark: { + background: '#2e3235', + lineBackground: '#2e323599', + foreground: '#bdbdbd', + caret: '#a0a4ae', + selection: '#d7d4f0', + selectionMatch: '#d7d4f0', + gutterBackground: '#2e3235', + gutterForeground: '#999', + gutterActiveForeground: '#4f5b66', + lineHighlight: '#545b61', + }, + materialLight: { + light: true, + background: '#FAFAFA', + lineBackground: '#FAFAFA99', + foreground: '#90A4AE', + caret: '#272727', + selection: '#80CBC440', + selectionMatch: '#FAFAFA', + gutterBackground: '#FAFAFA', + gutterForeground: '#90A4AE', + gutterBorder: 'transparent', + lineHighlight: '#CCD7DA50', + }, + noctisLilac: { + light: true, + background: '#f2f1f8', + lineBackground: '#f2f1f899', + foreground: '#0c006b', + caret: '#5c49e9', + selection: '#d5d1f2', + selectionMatch: '#d5d1f2', + gutterBackground: '#f2f1f8', + gutterForeground: '#0c006b70', + lineHighlight: '#e1def3', + }, + nord: { + background: '#2e3440', + lineBackground: '#2e344099', + foreground: '#FFFFFF', + caret: '#FFFFFF', + selection: '#3b4252', + selectionMatch: '#e5e9f0', + gutterBackground: '#2e3440', + gutterForeground: '#4c566a', + gutterActiveForeground: '#d8dee9', + lineHighlight: '#4c566a', + }, + okaidia: { + background: '#272822', + lineBackground: '#27282299', + foreground: '#FFFFFF', + caret: '#FFFFFF', + selection: '#49483E', + selectionMatch: '#49483E', + gutterBackground: '#272822', + gutterForeground: '#FFFFFF70', + lineHighlight: '#00000059', + }, + solarizedLight: { + light: true, + background: '#fdf6e3', + lineBackground: '#fdf6e399', + foreground: '#657b83', + caret: '#586e75', + selection: '#dfd9c8', + selectionMatch: '#dfd9c8', + gutterBackground: '#00000010', + gutterForeground: '#657b83', + lineHighlight: '#dfd9c8', + }, + solarizedDark: { + background: '#002b36', + lineBackground: '#002b3699', + foreground: '#93a1a1', + caret: '#839496', + selection: '#173541', + selectionMatch: '#aafe661a', + gutterBackground: '#00252f', + gutterForeground: '#839496', + lineHighlight: '#173541', + }, + sublime: { + background: '#303841', + lineBackground: '#30384199', + foreground: '#FFFFFF', + caret: '#FBAC52', + selection: '#4C5964', + selectionMatch: '#3A546E', + gutterBackground: '#303841', + gutterForeground: '#FFFFFF70', + lineHighlight: '#00000059', + }, + tokyoNightDay: { + light: true, + background: '#e1e2e7', + lineBackground: '#e1e2e799', + foreground: '#3760bf', + caret: '#3760bf', + selection: '#99a7df', + selectionMatch: '#99a7df', + gutterBackground: '#e1e2e7', + gutterForeground: '#3760bf', + gutterBorder: 'transparent', + lineHighlight: '#5f5faf11', + }, + tokyoNightStorm: { + background: '#24283b', + lineBackground: '#24283b99', + foreground: '#7982a9', + caret: '#c0caf5', + selection: '#6f7bb630', + selectionMatch: '#1f2335', + gutterBackground: '#24283b', + gutterForeground: '#7982a9', + gutterBorder: 'transparent', + lineHighlight: '#292e42', + }, + tokyoNight: { + background: '#1a1b26', + lineBackground: '#1a1b2699', + foreground: '#787c99', + caret: '#c0caf5', + selection: '#515c7e40', + selectionMatch: '#16161e', + gutterBackground: '#1a1b26', + gutterForeground: '#787c99', + gutterBorder: 'transparent', + lineHighlight: '#1e202e', + }, + vscodeDark: { + background: '#1e1e1e', + lineBackground: '#1e1e1e99', + foreground: '#9cdcfe', + caret: '#c6c6c6', + selection: '#6199ff2f', + selectionMatch: '#72a1ff59', + lineHighlight: '#ffffff0f', + gutterBackground: '#1e1e1e', + gutterForeground: '#838383', + gutterActiveForeground: '#fff', + }, + xcodeLight: { + light: true, + background: '#fff', + lineBackground: '#ffffff99', + foreground: '#3D3D3D', + selection: '#BBDFFF', + selectionMatch: '#BBDFFF', + gutterBackground: '#fff', + gutterForeground: '#AFAFAF', + lineHighlight: '#EDF4FF', + }, + xcodeDark: { + background: '#292A30', + lineBackground: '#292A3099', + foreground: '#CECFD0', + caret: '#fff', + selection: '#727377', + selectionMatch: '#727377', + lineHighlight: '#2F3239', + }, +}; + +function getColors(str) { + const colorRegex = /#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})/g; + const colors = []; + + let match; + while ((match = colorRegex.exec(str)) !== null) { + const color = match[0]; + if (!colors.includes(color)) { + colors.push(color); + } + } + + return colors; +} + +// TODO: remove +export function themeColors(theme) { + return getColors(stringifySafe(theme)); +} + +function getCircularReplacer() { + const seen = new WeakSet(); + return (key, value) => { + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) { + return; + } + seen.add(value); + } + return value; + }; +} + +function stringifySafe(json) { + return JSON.stringify(json, getCircularReplacer()); +} + +export const theme = (theme) => themes[theme] || themes.strudelTheme; + +// css style injection helpers +export function injectStyle(rule) { + const newStyle = document.createElement('style'); + document.head.appendChild(newStyle); + const styleSheet = newStyle.sheet; + const ruleIndex = styleSheet.insertRule(rule, 0); + return () => styleSheet.deleteRule(ruleIndex); +} + +let currentTheme, resetThemeStyle, themeStyle; +export function initTheme(theme) { + themeStyle = document.createElement('style'); + themeStyle.id = 'strudel-theme'; + document.head.append(themeStyle); + activateTheme(theme); +} + +export function activateTheme(name) { + if (currentTheme === name) { + return; + } + if (!settings[name]) { + console.warn('theme', name, 'has no settings.. defaulting to strudelTheme settings'); + } + const themeSettings = settings[name] || settings.strudelTheme; + // set css variables + themeStyle.innerHTML = `:root { + ${Object.entries(themeSettings) + // important to override fallback + .map(([key, value]) => `--${key}: ${value} !important;`) + .join('\n')} + }`; + // tailwind dark mode + if (themeSettings.light) { + document.documentElement.classList.remove('dark'); + } else { + document.documentElement.classList.add('dark'); + } + resetThemeStyle?.(); + resetThemeStyle = undefined; + if (themeSettings.customStyle) { + resetThemeStyle = injectStyle(themeSettings.customStyle); + } +} diff --git a/packages/codemirror/themes/algoboy.mjs b/packages/codemirror/themes/algoboy.mjs new file mode 100644 index 00000000..399370e1 --- /dev/null +++ b/packages/codemirror/themes/algoboy.mjs @@ -0,0 +1,41 @@ +import { tags as t } from '@lezer/highlight'; +import { createTheme } from '@uiw/codemirror-themes'; +export const settings = { + background: '#9bbc0f', + foreground: '#0f380f', // whats that? + caret: '#0f380f', + selection: '#306230', + selectionMatch: '#ffffff26', + lineHighlight: '#8bac0f', + lineBackground: '#9bbc0f50', + //lineBackground: 'transparent', + gutterBackground: 'transparent', + gutterForeground: '#0f380f', + light: true, + customStyle: '.cm-line { line-height: 1 }', +}; +export default createTheme({ + theme: 'light', + settings, + styles: [ + { tag: t.keyword, color: '#0f380f' }, + { tag: t.operator, color: '#0f380f' }, + { tag: t.special(t.variableName), color: '#0f380f' }, + { tag: t.typeName, color: '#0f380f' }, + { tag: t.atom, color: '#0f380f' }, + { tag: t.number, color: '#0f380f' }, + { tag: t.definition(t.variableName), color: '#0f380f' }, + { tag: t.string, color: '#0f380f' }, + { tag: t.special(t.string), color: '#0f380f' }, + { tag: t.comment, color: '#0f380f' }, + { tag: t.variableName, color: '#0f380f' }, + { tag: t.tagName, color: '#0f380f' }, + { tag: t.bracket, color: '#0f380f' }, + { tag: t.meta, color: '#0f380f' }, + { tag: t.attributeName, color: '#0f380f' }, + { tag: t.propertyName, color: '#0f380f' }, + { tag: t.className, color: '#0f380f' }, + { tag: t.invalid, color: '#0f380f' }, + { tag: [t.unit, t.punctuation], color: '#0f380f' }, + ], +}); diff --git a/packages/codemirror/themes/blackscreen.mjs b/packages/codemirror/themes/blackscreen.mjs new file mode 100644 index 00000000..135285a3 --- /dev/null +++ b/packages/codemirror/themes/blackscreen.mjs @@ -0,0 +1,38 @@ +import { tags as t } from '@lezer/highlight'; +import { createTheme } from '@uiw/codemirror-themes'; +export const settings = { + background: 'black', + foreground: 'white', // whats that? + caret: 'white', + selection: '#ffffff20', + selectionMatch: '#036dd626', + lineHighlight: '#ffffff10', + lineBackground: '#00000050', + gutterBackground: 'transparent', + gutterForeground: '#8a919966', +}; +export default createTheme({ + theme: 'dark', + settings, + styles: [ + { tag: t.keyword, color: 'white' }, + { tag: t.operator, color: 'white' }, + { tag: t.special(t.variableName), color: 'white' }, + { tag: t.typeName, color: 'white' }, + { tag: t.atom, color: 'white' }, + { tag: t.number, color: 'white' }, + { tag: t.definition(t.variableName), color: 'white' }, + { tag: t.string, color: 'white' }, + { tag: t.special(t.string), color: 'white' }, + { tag: t.comment, color: 'white' }, + { tag: t.variableName, color: 'white' }, + { tag: t.tagName, color: 'white' }, + { tag: t.bracket, color: 'white' }, + { tag: t.meta, color: 'white' }, + { tag: t.attributeName, color: 'white' }, + { tag: t.propertyName, color: 'white' }, + { tag: t.className, color: 'white' }, + { tag: t.invalid, color: 'white' }, + { tag: [t.unit, t.punctuation], color: 'white' }, + ], +}); diff --git a/packages/codemirror/themes/bluescreen.mjs b/packages/codemirror/themes/bluescreen.mjs new file mode 100644 index 00000000..aa6489d6 --- /dev/null +++ b/packages/codemirror/themes/bluescreen.mjs @@ -0,0 +1,41 @@ +import { tags as t } from '@lezer/highlight'; +import { createTheme } from '@uiw/codemirror-themes'; +export const settings = { + background: '#051DB5', + lineBackground: '#051DB550', + foreground: 'white', // whats that? + caret: 'white', + selection: 'rgba(128, 203, 196, 0.5)', + selectionMatch: '#036dd626', + // lineHighlight: '#8a91991a', // original + lineHighlight: '#00000050', + gutterBackground: 'transparent', + // gutterForeground: '#8a919966', + gutterForeground: '#8a919966', +}; + +export default createTheme({ + theme: 'dark', + settings, + styles: [ + { tag: t.keyword, color: 'white' }, + { tag: t.operator, color: 'white' }, + { tag: t.special(t.variableName), color: 'white' }, + { tag: t.typeName, color: 'white' }, + { tag: t.atom, color: 'white' }, + { tag: t.number, color: 'white' }, + { tag: t.definition(t.variableName), color: 'white' }, + { tag: t.string, color: 'white' }, + { tag: t.special(t.string), color: 'white' }, + { tag: t.comment, color: 'white' }, + { tag: t.variableName, color: 'white' }, + { tag: t.tagName, color: 'white' }, + { tag: t.bracket, color: 'white' }, + { tag: t.meta, color: 'white' }, + { tag: t.attributeName, color: 'white' }, + { tag: t.propertyName, color: 'white' }, + { tag: t.className, color: 'white' }, + { tag: t.invalid, color: 'white' }, + { tag: [t.unit, t.punctuation], color: 'white' }, + ], +}); diff --git a/packages/codemirror/themes/one-dark.mjs b/packages/codemirror/themes/one-dark.mjs deleted file mode 100644 index cce83699..00000000 --- a/packages/codemirror/themes/one-dark.mjs +++ /dev/null @@ -1,139 +0,0 @@ -import { EditorView } from '@codemirror/view'; -import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; -import { tags as t } from '@lezer/highlight'; - -// Using https://github.com/one-dark/vscode-one-dark-theme/ as reference for the colors - -const chalky = '#e5c07b', - coral = '#e06c75', - cyan = '#56b6c2', - invalid = '#ffffff', - ivory = '#abb2bf', - stone = '#7d8799', // Brightened compared to original to increase contrast - malibu = '#61afef', - sage = '#98c379', - whiskey = '#d19a66', - violet = '#c678dd', - darkBackground = '#21252b', - highlightBackground = '#2c313a', - background = '#282c34', - tooltipBackground = '#353a42', - selection = '#3E4451', - cursor = '#528bff'; - -/// The colors used in the theme, as CSS color strings. -export const color = { - chalky, - coral, - cyan, - invalid, - ivory, - stone, - malibu, - sage, - whiskey, - violet, - darkBackground, - highlightBackground, - background, - tooltipBackground, - selection, - cursor, -}; - -/// The editor theme styles for One Dark. -export const oneDarkTheme = EditorView.theme( - { - '&': { - color: ivory, - backgroundColor: background, - }, - - '.cm-content': { - caretColor: cursor, - }, - - '.cm-cursor, .cm-dropCursor': { borderLeftColor: cursor }, - '&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': - { backgroundColor: selection }, - - '.cm-panels': { backgroundColor: darkBackground, color: ivory }, - '.cm-panels.cm-panels-top': { borderBottom: '2px solid black' }, - '.cm-panels.cm-panels-bottom': { borderTop: '2px solid black' }, - - '.cm-searchMatch': { - backgroundColor: '#72a1ff59', - outline: '1px solid #457dff', - }, - '.cm-searchMatch.cm-searchMatch-selected': { - backgroundColor: '#6199ff2f', - }, - - '.cm-activeLine': { backgroundColor: '#6699ff0b' }, - '.cm-selectionMatch': { backgroundColor: '#aafe661a' }, - - '&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket': { - backgroundColor: '#bad0f847', - }, - - '.cm-gutters': { - backgroundColor: background, - color: stone, - border: 'none', - }, - - '.cm-activeLineGutter': { - backgroundColor: highlightBackground, - }, - - '.cm-foldPlaceholder': { - backgroundColor: 'transparent', - border: 'none', - color: '#ddd', - }, - - '.cm-tooltip': { - border: 'none', - backgroundColor: tooltipBackground, - }, - '.cm-tooltip .cm-tooltip-arrow:before': { - borderTopColor: 'transparent', - borderBottomColor: 'transparent', - }, - '.cm-tooltip .cm-tooltip-arrow:after': { - borderTopColor: tooltipBackground, - borderBottomColor: tooltipBackground, - }, - '.cm-tooltip-autocomplete': { - '& > ul > li[aria-selected]': { - backgroundColor: highlightBackground, - color: ivory, - }, - }, - }, - { dark: true }, -); - -/// The highlighting style for code in the One Dark theme. -export const oneDarkHighlightStyle = HighlightStyle.define([ - { tag: t.keyword, color: violet }, - { tag: [t.name, t.deleted, t.character, t.propertyName, t.macroName], color: coral }, - { tag: [t.function(t.variableName), t.labelName], color: malibu }, - { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: whiskey }, - { tag: [t.definition(t.name), t.separator], color: ivory }, - { tag: [t.typeName, t.className, t.number, t.changed, t.annotation, t.modifier, t.self, t.namespace], color: chalky }, - { tag: [t.operator, t.operatorKeyword, t.url, t.escape, t.regexp, t.link, t.special(t.string)], color: cyan }, - { tag: [t.meta, t.comment], color: stone }, - { tag: t.strong, fontWeight: 'bold' }, - { tag: t.emphasis, fontStyle: 'italic' }, - { tag: t.strikethrough, textDecoration: 'line-through' }, - { tag: t.link, color: stone, textDecoration: 'underline' }, - { tag: t.heading, fontWeight: 'bold', color: coral }, - { tag: [t.atom, t.bool, t.special(t.variableName)], color: whiskey }, - { tag: [t.processingInstruction, t.string, t.inserted], color: sage }, - { tag: t.invalid, color: invalid }, -]); - -/// Extension to enable the One Dark theme (both the editor theme and -/// the highlight style). -export const oneDark = [oneDarkTheme, syntaxHighlighting(oneDarkHighlightStyle)]; diff --git a/packages/codemirror/themes/strudel-theme.mjs b/packages/codemirror/themes/strudel-theme.mjs new file mode 100644 index 00000000..4ae31060 --- /dev/null +++ b/packages/codemirror/themes/strudel-theme.mjs @@ -0,0 +1,45 @@ +import { tags as t } from '@lezer/highlight'; +import { createTheme } from '@uiw/codemirror-themes'; +export default createTheme({ + theme: 'dark', + settings: { + background: '#222', + foreground: '#75baff', // whats that? + caret: '#ffcc00', + selection: 'rgba(128, 203, 196, 0.5)', + selectionMatch: '#036dd626', + // lineHighlight: '#8a91991a', // original + lineHighlight: '#00000050', + gutterBackground: 'transparent', + // gutterForeground: '#8a919966', + gutterForeground: '#8a919966', + }, + styles: [ + { tag: t.keyword, color: '#c792ea' }, + { tag: t.operator, color: '#89ddff' }, + { tag: t.special(t.variableName), color: '#eeffff' }, + // { tag: t.typeName, color: '#f07178' }, // original + { tag: t.typeName, color: '#c3e88d' }, + { tag: t.atom, color: '#f78c6c' }, + // { tag: t.number, color: '#ff5370' }, // original + { tag: t.number, color: '#c3e88d' }, + { tag: t.definition(t.variableName), color: '#82aaff' }, + { tag: t.string, color: '#c3e88d' }, + // { tag: t.special(t.string), color: '#f07178' }, // original + { tag: t.special(t.string), color: '#c3e88d' }, + { tag: t.comment, color: '#7d8799' }, + // { tag: t.variableName, color: '#f07178' }, // original + { tag: t.variableName, color: '#c792ea' }, + // { tag: t.tagName, color: '#ff5370' }, // original + { tag: t.tagName, color: '#c3e88d' }, + { tag: t.bracket, color: '#525154' }, + // { tag: t.bracket, color: '#a2a1a4' }, // original + { tag: t.meta, color: '#ffcb6b' }, + { tag: t.attributeName, color: '#c792ea' }, + { tag: t.propertyName, color: '#c792ea' }, + + { tag: t.className, color: '#decb6b' }, + { tag: t.invalid, color: '#ffffff' }, + { tag: [t.unit, t.punctuation], color: '#82aaff' }, + ], +}); diff --git a/packages/codemirror/themes/teletext.mjs b/packages/codemirror/themes/teletext.mjs new file mode 100644 index 00000000..5fd9a557 --- /dev/null +++ b/packages/codemirror/themes/teletext.mjs @@ -0,0 +1,50 @@ +import { tags as t } from '@lezer/highlight'; +import { createTheme } from '@uiw/codemirror-themes'; + +let colorA = '#6edee4'; +//let colorB = 'magenta'; +let colorB = 'white'; +let colorC = 'red'; +let colorD = '#f8fc55'; + +export const settings = { + background: '#000000', + foreground: colorA, // whats that? + caret: colorC, + selection: colorD, + selectionMatch: colorA, + lineHighlight: '#6edee440', // panel bg + lineBackground: '#00000040', + gutterBackground: 'transparent', + gutterForeground: '#8a919966', + customStyle: '.cm-line { line-height: 1 }', +}; + +let punctuation = colorD; +let mini = colorB; + +export default createTheme({ + theme: 'dark', + settings, + styles: [ + { tag: t.keyword, color: colorA }, + { tag: t.operator, color: mini }, + { tag: t.special(t.variableName), color: colorA }, + { tag: t.typeName, color: colorA }, + { tag: t.atom, color: colorA }, + { tag: t.number, color: mini }, + { tag: t.definition(t.variableName), color: colorA }, + { tag: t.string, color: mini }, + { tag: t.special(t.string), color: mini }, + { tag: t.comment, color: punctuation }, + { tag: t.variableName, color: colorA }, + { tag: t.tagName, color: colorA }, + { tag: t.bracket, color: punctuation }, + { tag: t.meta, color: colorA }, + { tag: t.attributeName, color: colorA }, + { tag: t.propertyName, color: colorA }, // methods + { tag: t.className, color: colorA }, + { tag: t.invalid, color: colorC }, + { tag: [t.unit, t.punctuation], color: punctuation }, + ], +}); diff --git a/packages/codemirror/themes/terminal.mjs b/packages/codemirror/themes/terminal.mjs new file mode 100644 index 00000000..1374bb86 --- /dev/null +++ b/packages/codemirror/themes/terminal.mjs @@ -0,0 +1,36 @@ +import { tags as t } from '@lezer/highlight'; +import { createTheme } from '@uiw/codemirror-themes'; +export const settings = { + background: 'black', + foreground: '#41FF00', // whats that? + caret: '#41FF00', + selection: '#ffffff20', + selectionMatch: '#036dd626', + lineHighlight: '#ffffff10', + gutterBackground: 'transparent', + gutterForeground: '#8a919966', +}; +export default createTheme({ + theme: 'dark', + settings, + styles: [ + { tag: t.keyword, color: '#41FF00' }, + { tag: t.operator, color: '#41FF00' }, + { tag: t.special(t.variableName), color: '#41FF00' }, + { tag: t.typeName, color: '#41FF00' }, + { tag: t.atom, color: '#41FF00' }, + { tag: t.number, color: '#41FF00' }, + { tag: t.definition(t.variableName), color: '#41FF00' }, + { tag: t.string, color: '#41FF00' }, + { tag: t.special(t.string), color: '#41FF00' }, + { tag: t.comment, color: '#41FF00' }, + { tag: t.variableName, color: '#41FF00' }, + { tag: t.tagName, color: '#41FF00' }, + { tag: t.bracket, color: '#41FF00' }, + { tag: t.meta, color: '#41FF00' }, + { tag: t.attributeName, color: '#41FF00' }, + { tag: t.propertyName, color: '#41FF00' }, + { tag: t.className, color: '#41FF00' }, + { tag: t.invalid, color: '#41FF00' }, + ], +}); diff --git a/packages/codemirror/themes/whitescreen.mjs b/packages/codemirror/themes/whitescreen.mjs new file mode 100644 index 00000000..22abad9e --- /dev/null +++ b/packages/codemirror/themes/whitescreen.mjs @@ -0,0 +1,38 @@ +import { tags as t } from '@lezer/highlight'; +import { createTheme } from '@uiw/codemirror-themes'; +export const settings = { + background: 'white', + foreground: 'black', // whats that? + caret: 'black', + selection: 'rgba(128, 203, 196, 0.5)', + selectionMatch: '#ffffff26', + lineHighlight: '#cccccc50', + lineBackground: '#ffffff50', + gutterBackground: 'transparent', + gutterForeground: 'black', + light: true, +}; +export default createTheme({ + theme: 'light', + settings, + styles: [ + { tag: t.keyword, color: 'black' }, + { tag: t.operator, color: 'black' }, + { tag: t.special(t.variableName), color: 'black' }, + { tag: t.typeName, color: 'black' }, + { tag: t.atom, color: 'black' }, + { tag: t.number, color: 'black' }, + { tag: t.definition(t.variableName), color: 'black' }, + { tag: t.string, color: 'black' }, + { tag: t.special(t.string), color: 'black' }, + { tag: t.comment, color: 'black' }, + { tag: t.variableName, color: 'black' }, + { tag: t.tagName, color: 'black' }, + { tag: t.bracket, color: 'black' }, + { tag: t.meta, color: 'black' }, + { tag: t.attributeName, color: 'black' }, + { tag: t.propertyName, color: 'black' }, + { tag: t.className, color: 'black' }, + { tag: t.invalid, color: 'black' }, + ], +}); diff --git a/packages/core/util.mjs b/packages/core/util.mjs index 695eaacf..2b190c82 100644 --- a/packages/core/util.mjs +++ b/packages/core/util.mjs @@ -274,3 +274,31 @@ export const sol2note = (n, notation = 'letters') => { const oct = Math.floor(n / 12) - 1; return note + oct; }; + +// code hashing helpers + +export function unicodeToBase64(text) { + const utf8Bytes = new TextEncoder().encode(text); + const base64String = btoa(String.fromCharCode(...utf8Bytes)); + return base64String; +} + +export function base64ToUnicode(base64String) { + const utf8Bytes = new Uint8Array( + atob(base64String) + .split('') + .map((char) => char.charCodeAt(0)), + ); + const decodedText = new TextDecoder().decode(utf8Bytes); + return decodedText; +} + +export function code2hash(code) { + return encodeURIComponent(unicodeToBase64(code)); + //return '#' + encodeURIComponent(btoa(code)); +} + +export function hash2code(hash) { + return base64ToUnicode(decodeURIComponent(hash)); + //return atob(decodeURIComponent(codeParam || '')); +} diff --git a/packages/osc/osc.mjs b/packages/osc/osc.mjs index f77f0ac5..561dbda5 100644 --- a/packages/osc/osc.mjs +++ b/packages/osc/osc.mjs @@ -37,7 +37,7 @@ function connect() { /** * * Sends each hap as an OSC message, which can be picked up by SuperCollider or any other OSC-enabled software. - * For more info, read [MIDI & OSC in the docs](https://strudel.cc/learn/input-output) + * For more info, read [MIDI & OSC in the docs](https://strudel.cc/learn/input-output/) * * @name osc * @memberof Pattern diff --git a/packages/soundfonts/fontloader.mjs b/packages/soundfonts/fontloader.mjs index 5c6759f5..017ba763 100644 --- a/packages/soundfonts/fontloader.mjs +++ b/packages/soundfonts/fontloader.mjs @@ -139,8 +139,8 @@ export function registerSoundfonts() { const { node: envelope, stop: releaseEnvelope } = getEnvelope(attack, decay, sustain, release, 0.3, time); bufferSource.connect(envelope); const stop = (releaseTime) => { - bufferSource.stop(releaseTime + release); - releaseEnvelope(releaseTime); + const silentAt = releaseEnvelope(releaseTime); + bufferSource.stop(silentAt); }; bufferSource.onended = () => { bufferSource.disconnect(); diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index 94bdc11f..d75f5b14 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -10,21 +10,24 @@ export function gainNode(value) { // alternative to getADSR returning the gain node and a stop handle to trigger the release anytime in the future export const getEnvelope = (attack, decay, sustain, release, velocity, begin) => { const gainNode = getAudioContext().createGain(); + let phase = begin; gainNode.gain.setValueAtTime(0, begin); - gainNode.gain.linearRampToValueAtTime(velocity, begin + attack); // attack - gainNode.gain.linearRampToValueAtTime(sustain * velocity, begin + attack + decay); // sustain start + phase += attack; + gainNode.gain.linearRampToValueAtTime(velocity, phase); // attack + phase += decay; + let sustainLevel = sustain * velocity; + gainNode.gain.linearRampToValueAtTime(sustainLevel, phase); // decay / sustain // sustain end return { node: gainNode, stop: (t) => { - //if (typeof gainNode.gain.cancelAndHoldAtTime === 'function') { - // gainNode.gain.cancelAndHoldAtTime(t); // this seems to release instantly.... - // see https://discord.com/channels/779427371270275082/937365093082079272/1086053607360712735 - //} else { - // firefox: this will glitch when the sustain has not been reached yet at the time of release - gainNode.gain.setValueAtTime(sustain * velocity, t); - //} - gainNode.gain.linearRampToValueAtTime(0, t + release); + // to make sure the release won't begin before sustain is reached + phase = Math.max(t, phase); + // see https://github.com/tidalcycles/strudel/issues/522 + gainNode.gain.setValueAtTime(sustainLevel, phase); + phase += release; + gainNode.gain.linearRampToValueAtTime(0, phase); // release + return phase; }, }; }; diff --git a/packages/superdough/package.json b/packages/superdough/package.json index 12e28438..22426901 100644 --- a/packages/superdough/package.json +++ b/packages/superdough/package.json @@ -1,6 +1,6 @@ { "name": "superdough", - "version": "0.9.11", + "version": "0.9.12", "description": "simple web audio synth and sampler intended for live coding. inspired by superdirt and webdirt.", "main": "index.mjs", "type": "module", diff --git a/packages/superdough/sampler.mjs b/packages/superdough/sampler.mjs index fb1dfda9..2745bde8 100644 --- a/packages/superdough/sampler.mjs +++ b/packages/superdough/sampler.mjs @@ -313,8 +313,8 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) { const bufferDuration = bufferSource.buffer.duration / bufferSource.playbackRate.value; releaseTime = t + (end - begin) * bufferDuration; } - bufferSource.stop(releaseTime + release); - releaseEnvelope(releaseTime); + const silentAt = releaseEnvelope(releaseTime); + bufferSource.stop(silentAt); }; const handle = { node: out, bufferSource, stop }; diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs index 5977e9ac..74255dbf 100644 --- a/packages/superdough/synth.mjs +++ b/packages/superdough/synth.mjs @@ -59,10 +59,9 @@ export function registerSynthSounds() { return { node: o.connect(g).connect(envelope), stop: (releaseTime) => { - releaseEnvelope(releaseTime); + const silentAt = releaseEnvelope(releaseTime); triggerRelease?.(releaseTime); - let end = releaseTime + release; - stop(end); + stop(silentAt); }, }; }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98f6441f..a2416a62 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,5 +1,9 @@ lockfileVersion: '6.0' +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + importers: .: @@ -71,6 +75,9 @@ importers: packages/codemirror: dependencies: + '@codemirror/autocomplete': + specifier: ^6.6.0 + version: 6.6.0(@codemirror/language@6.6.0)(@codemirror/state@6.2.0)(@codemirror/view@6.10.0)(@lezer/common@1.0.2) '@codemirror/commands': specifier: ^6.2.4 version: 6.2.4 @@ -80,6 +87,9 @@ importers: '@codemirror/language': specifier: ^6.6.0 version: 6.6.0 + '@codemirror/search': + specifier: ^6.0.0 + version: 6.2.3 '@codemirror/state': specifier: ^6.2.0 version: 6.2.0 @@ -89,14 +99,78 @@ importers: '@lezer/highlight': specifier: ^1.1.4 version: 1.1.4 + '@replit/codemirror-emacs': + specifier: ^6.0.1 + version: 6.0.1(@codemirror/autocomplete@6.6.0)(@codemirror/commands@6.2.4)(@codemirror/search@6.2.3)(@codemirror/state@6.2.0)(@codemirror/view@6.10.0) + '@replit/codemirror-vim': + specifier: ^6.0.14 + version: 6.0.14(@codemirror/commands@6.2.4)(@codemirror/language@6.6.0)(@codemirror/search@6.2.3)(@codemirror/state@6.2.0)(@codemirror/view@6.10.0) + '@replit/codemirror-vscode-keymap': + specifier: ^6.0.2 + version: 6.0.2(@codemirror/autocomplete@6.6.0)(@codemirror/commands@6.2.4)(@codemirror/language@6.6.0)(@codemirror/lint@6.1.0)(@codemirror/search@6.2.3)(@codemirror/state@6.2.0)(@codemirror/view@6.10.0) '@strudel.cycles/core': specifier: workspace:* version: link:../core + '@uiw/codemirror-themes': + specifier: ^4.19.16 + version: 4.19.16(@codemirror/language@6.6.0)(@codemirror/state@6.2.0)(@codemirror/view@6.10.0) + '@uiw/codemirror-themes-all': + specifier: ^4.19.16 + version: 4.19.16(@codemirror/language@6.6.0)(@codemirror/state@6.2.0)(@codemirror/view@6.10.0) + react-dom: + specifier: ^18.2.0 + version: 18.2.0(react@18.2.0) devDependencies: vite: specifier: ^4.3.3 version: 4.3.3 + packages/codemirror/examples/strudelmirror: + dependencies: + '@strudel.cycles/core': + specifier: workspace:* + version: link:../../../core + '@strudel.cycles/csound': + specifier: workspace:* + version: link:../../../csound + '@strudel.cycles/midi': + specifier: workspace:* + version: link:../../../midi + '@strudel.cycles/mini': + specifier: workspace:* + version: link:../../../mini + '@strudel.cycles/osc': + specifier: workspace:* + version: link:../../../osc + '@strudel.cycles/serial': + specifier: workspace:* + version: link:../../../serial + '@strudel.cycles/soundfonts': + specifier: workspace:* + version: link:../../../soundfonts + '@strudel.cycles/tonal': + specifier: workspace:* + version: link:../../../tonal + '@strudel.cycles/transpiler': + specifier: workspace:* + version: link:../../../transpiler + '@strudel.cycles/webaudio': + specifier: workspace:* + version: link:../../../webaudio + '@strudel.cycles/xen': + specifier: workspace:* + version: link:../../../xen + '@strudel/codemirror': + specifier: workspace:* + version: link:../.. + '@strudel/hydra': + specifier: workspace:* + version: link:../../../hydra + devDependencies: + vite: + specifier: ^5.0.8 + version: 5.0.8 + packages/core: dependencies: fraction.js: @@ -4053,6 +4127,110 @@ packages: rollup: 2.79.1 dev: true + /@rollup/rollup-android-arm-eabi@4.9.0: + resolution: {integrity: sha512-+1ge/xmaJpm1KVBuIH38Z94zj9fBD+hp+/5WLaHgyY8XLq1ibxk/zj6dTXaqM2cAbYKq8jYlhHd6k05If1W5xA==} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-android-arm64@4.9.0: + resolution: {integrity: sha512-im6hUEyQ7ZfoZdNvtwgEJvBWZYauC9KVKq1w58LG2Zfz6zMd8gRrbN+xCVoqA2hv/v6fm9lp5LFGJ3za8EQH3A==} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-darwin-arm64@4.9.0: + resolution: {integrity: sha512-u7aTMskN6Dmg1lCT0QJ+tINRt+ntUrvVkhbPfFz4bCwRZvjItx2nJtwJnJRlKMMaQCHRjrNqHRDYvE4mBm3DlQ==} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-darwin-x64@4.9.0: + resolution: {integrity: sha512-8FvEl3w2ExmpcOmX5RJD0yqXcVSOqAJJUJ29Lca29Ik+3zPS1yFimr2fr5JSZ4Z5gt8/d7WqycpgkX9nocijSw==} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-arm-gnueabihf@4.9.0: + resolution: {integrity: sha512-lHoKYaRwd4gge+IpqJHCY+8Vc3hhdJfU6ukFnnrJasEBUvVlydP8PuwndbWfGkdgSvZhHfSEw6urrlBj0TSSfg==} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-arm64-gnu@4.9.0: + resolution: {integrity: sha512-JbEPfhndYeWHfOSeh4DOFvNXrj7ls9S/2omijVsao+LBPTPayT1uKcK3dHW3MwDJ7KO11t9m2cVTqXnTKpeaiw==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-arm64-musl@4.9.0: + resolution: {integrity: sha512-ahqcSXLlcV2XUBM3/f/C6cRoh7NxYA/W7Yzuv4bDU1YscTFw7ay4LmD7l6OS8EMhTNvcrWGkEettL1Bhjf+B+w==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-riscv64-gnu@4.9.0: + resolution: {integrity: sha512-uwvOYNtLw8gVtrExKhdFsYHA/kotURUmZYlinH2VcQxNCQJeJXnkmWgw2hI9Xgzhgu7J9QvWiq9TtTVwWMDa+w==} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-x64-gnu@4.9.0: + resolution: {integrity: sha512-m6pkSwcZZD2LCFHZX/zW2aLIISyzWLU3hrLLzQKMI12+OLEzgruTovAxY5sCZJkipklaZqPy/2bEEBNjp+Y7xg==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-x64-musl@4.9.0: + resolution: {integrity: sha512-VFAC1RDRSbU3iOF98X42KaVicAfKf0m0OvIu8dbnqhTe26Kh6Ym9JrDulz7Hbk7/9zGc41JkV02g+p3BivOdAg==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-win32-arm64-msvc@4.9.0: + resolution: {integrity: sha512-9jPgMvTKXARz4inw6jezMLA2ihDBvgIU9Ml01hjdVpOcMKyxFBJrn83KVQINnbeqDv0+HdO1c09hgZ8N0s820Q==} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-win32-ia32-msvc@4.9.0: + resolution: {integrity: sha512-WE4pT2kTXQN2bAv40Uog0AsV7/s9nT9HBWXAou8+++MBCnY51QS02KYtm6dQxxosKi1VIz/wZIrTQO5UP2EW+Q==} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-win32-x64-msvc@4.9.0: + resolution: {integrity: sha512-aPP5Q5AqNGuT0tnuEkK/g4mnt3ZhheiXrDIiSVIHN9mcN21OyXDVbEMqmXPE7e2OplNLDkcvV+ZoGJa2ZImFgw==} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + /@sigstore/protobuf-specs@0.1.0: resolution: {integrity: sha512-a31EnjuIDSX8IXBUib3cYLDRlPMU36AWX4xS8ysLaNu4ZzUesDiPt83pgrW2X1YLMe5L2HbDyaKK5BrL4cNKaQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -5572,6 +5750,7 @@ packages: /b4a@1.6.4: resolution: {integrity: sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==} + requiresBuild: true optional: true /babel-plugin-add-module-exports@0.2.1: @@ -5968,7 +6147,7 @@ packages: normalize-path: 3.0.0 readdirp: 3.6.0 optionalDependencies: - fsevents: 2.3.2 + fsevents: 2.3.3 /chord-voicings@0.0.1: resolution: {integrity: sha512-SutgB/4ynkkuiK6qdQ/k3QvCFcH0Vj8Ch4t6LbRyRQbVzP/TOztiCk3kvXd516UZ6fqk7ijDRELEFcKN+6V8sA==} @@ -6154,6 +6333,7 @@ packages: /color-string@1.9.1: resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + requiresBuild: true dependencies: color-name: 1.1.4 simple-swizzle: 0.2.2 @@ -6166,6 +6346,7 @@ packages: /color@4.2.3: resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} engines: {node: '>=12.5.0'} + requiresBuild: true dependencies: color-convert: 2.0.1 color-string: 1.9.1 @@ -6629,6 +6810,7 @@ packages: /detect-libc@2.0.2: resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} engines: {node: '>=8'} + requiresBuild: true optional: true /detective-amd@4.0.1: @@ -7472,6 +7654,7 @@ packages: /fast-fifo@1.3.2: resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + requiresBuild: true optional: true /fast-glob@3.2.12: @@ -7736,8 +7919,8 @@ packages: /fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - /fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + /fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] requiresBuild: true @@ -10521,6 +10704,12 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + /nanoid@3.3.7: + resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + dev: true + /nanoid@4.0.2: resolution: {integrity: sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==} engines: {node: ^14 || ^16 || >=18} @@ -10579,6 +10768,7 @@ packages: /node-addon-api@6.1.0: resolution: {integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==} + requiresBuild: true optional: true /node-domexception@1.0.0: @@ -11675,6 +11865,15 @@ packages: picocolors: 1.0.0 source-map-js: 1.0.2 + /postcss@8.4.32: + resolution: {integrity: sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.7 + picocolors: 1.0.0 + source-map-js: 1.0.2 + dev: true + /prebuild-install@7.1.1: resolution: {integrity: sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==} engines: {node: '>=10'} @@ -11875,6 +12074,7 @@ packages: /queue-tick@1.0.1: resolution: {integrity: sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==} + requiresBuild: true optional: true /quick-lru@4.0.1: @@ -12504,7 +12704,7 @@ packages: engines: {node: '>=10.0.0'} hasBin: true optionalDependencies: - fsevents: 2.3.2 + fsevents: 2.3.3 dev: true /rollup@3.21.0: @@ -12512,7 +12712,7 @@ packages: engines: {node: '>=14.18.0', npm: '>=8.0.0'} hasBin: true optionalDependencies: - fsevents: 2.3.2 + fsevents: 2.3.3 dev: true /rollup@3.28.0: @@ -12520,7 +12720,28 @@ packages: engines: {node: '>=14.18.0', npm: '>=8.0.0'} hasBin: true optionalDependencies: - fsevents: 2.3.2 + fsevents: 2.3.3 + + /rollup@4.9.0: + resolution: {integrity: sha512-bUHW/9N21z64gw8s6tP4c88P382Bq/L5uZDowHlHx6s/QWpjJXivIAbEw6LZthgSvlEizZBfLC4OAvWe7aoF7A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.9.0 + '@rollup/rollup-android-arm64': 4.9.0 + '@rollup/rollup-darwin-arm64': 4.9.0 + '@rollup/rollup-darwin-x64': 4.9.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.9.0 + '@rollup/rollup-linux-arm64-gnu': 4.9.0 + '@rollup/rollup-linux-arm64-musl': 4.9.0 + '@rollup/rollup-linux-riscv64-gnu': 4.9.0 + '@rollup/rollup-linux-x64-gnu': 4.9.0 + '@rollup/rollup-linux-x64-musl': 4.9.0 + '@rollup/rollup-win32-arm64-msvc': 4.9.0 + '@rollup/rollup-win32-ia32-msvc': 4.9.0 + '@rollup/rollup-win32-x64-msvc': 4.9.0 + fsevents: 2.3.3 + dev: true /run-async@2.4.1: resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} @@ -12730,6 +12951,7 @@ packages: /simple-swizzle@0.2.2: resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + requiresBuild: true dependencies: is-arrayish: 0.3.2 optional: true @@ -12943,6 +13165,7 @@ packages: /streamx@2.15.2: resolution: {integrity: sha512-b62pAV/aeMjUoRN2C/9F0n+G8AfcJjNC0zw/ZmOHeFsIe4m4GzjVW9m6VHXVjk536NbdU9JRwKMJRfkc+zUFTg==} + requiresBuild: true dependencies: fast-fifo: 1.3.2 queue-tick: 1.0.1 @@ -13220,6 +13443,7 @@ packages: /tar-fs@3.0.4: resolution: {integrity: sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==} + requiresBuild: true dependencies: mkdirp-classic: 0.5.3 pump: 3.0.0 @@ -13238,6 +13462,7 @@ packages: /tar-stream@3.1.6: resolution: {integrity: sha512-B/UyjYwPpMBv+PaFSWAmtYjwdrlEaZQEhMIBFNC5oEG8lpiW8XjcSdmEaClj28ArfKScKHs2nshz3k2le6crsg==} + requiresBuild: true dependencies: b4a: 1.6.4 fast-fifo: 1.3.2 @@ -14008,7 +14233,7 @@ packages: mlly: 1.4.0 pathe: 1.1.1 picocolors: 1.0.0 - vite: 4.4.5(@types/node@18.16.3) + vite: 4.5.0(@types/node@18.16.3) transitivePeerDependencies: - '@types/node' - less @@ -14067,7 +14292,7 @@ packages: postcss: 8.4.23 rollup: 3.21.0 optionalDependencies: - fsevents: 2.3.2 + fsevents: 2.3.3 dev: true /vite@4.4.5(@types/node@18.16.3): @@ -14103,7 +14328,7 @@ packages: postcss: 8.4.27 rollup: 3.28.0 optionalDependencies: - fsevents: 2.3.2 + fsevents: 2.3.3 dev: true /vite@4.5.0(@types/node@18.16.3): @@ -14139,7 +14364,42 @@ packages: postcss: 8.4.31 rollup: 3.28.0 optionalDependencies: - fsevents: 2.3.2 + fsevents: 2.3.3 + + /vite@5.0.8: + resolution: {integrity: sha512-jYMALd8aeqR3yS9xlHd0OzQJndS9fH5ylVgWdB+pxTwxLKdO1pgC5Dlb398BUxpfaBxa4M9oT7j1g503Gaj5IQ==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + dependencies: + esbuild: 0.19.5 + postcss: 8.4.32 + rollup: 4.9.0 + optionalDependencies: + fsevents: 2.3.3 + dev: true /vitefu@0.2.4(vite@4.5.0): resolution: {integrity: sha512-fanAXjSaf9xXtOOeno8wZXIhgia+CZury481LsDaV++lSvcU2R9Ch2bPh3PYFyoHW+w9LqAeYRISVQjUIew14g==} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 615475e4..baafa3c3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,3 +7,4 @@ packages: - "packages/react/examples/nano-repl" - "packages/web/examples/repl-example" - "packages/superdough/example" + - "packages/codemirror/examples/strudelmirror" diff --git a/website/src/components/HeadCommonNew.astro b/website/src/components/HeadCommonNew.astro new file mode 100644 index 00000000..9f323a7a --- /dev/null +++ b/website/src/components/HeadCommonNew.astro @@ -0,0 +1,58 @@ +--- +import { pwaInfo } from 'virtual:pwa-info'; +import '../styles/index.css'; + +const { BASE_URL } = import.meta.env; +const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL; +--- + + + + + + + + + + + + + + + + + + + + + + + +{pwaInfo && } + + diff --git a/website/src/pages/learn/sounds.mdx b/website/src/pages/learn/sounds.mdx index a9fd547c..dbe27c55 100644 --- a/website/src/pages/learn/sounds.mdx +++ b/website/src/pages/learn/sounds.mdx @@ -37,4 +37,4 @@ What about combining different notes with different sounds at the same time? Hmm, something interesting is going on there, related to there being five notes and three sounds. -Let's now take a step back and think about the Strudel [Code](/learn/code) we've been hearing so far. +Let's now take a step back and think about the Strudel [Code](/learn/code/) we've been hearing so far. diff --git a/website/src/pages/learn/tonal.mdx b/website/src/pages/learn/tonal.mdx index e010e143..130c7880 100644 --- a/website/src/pages/learn/tonal.mdx +++ b/website/src/pages/learn/tonal.mdx @@ -64,4 +64,4 @@ Together with layer, struct and voicings, this can be used to create a basic bac )`} /> -So far, we've stayed within the browser. [MIDI and OSC](/learn/input-output) are ways to break out of it. +So far, we've stayed within the browser. [MIDI and OSC](/learn/input-output/) are ways to break out of it. diff --git a/website/src/pages/vanilla/index.astro b/website/src/pages/vanilla/index.astro new file mode 100644 index 00000000..44144bb6 --- /dev/null +++ b/website/src/pages/vanilla/index.astro @@ -0,0 +1,96 @@ +--- +import HeadCommonNew from '../../components/HeadCommonNew.astro'; +--- + + + + + Strudel Vanilla REPL + + +
+
+
+
+ +
+ +
+ +
+ +
+ +
+ +
+ + + +
+
+
+ + + diff --git a/website/src/repl/Header.jsx b/website/src/repl/Header.jsx index 5e991f8a..b6f13060 100644 --- a/website/src/repl/Header.jsx +++ b/website/src/repl/Header.jsx @@ -87,7 +87,7 @@ export function Header({ context }) { )} + )} + + {!isExample && ( + + )} +
+ + )} +
+ {Object.entries(userPatterns).map(([key, up]) => ( + { + const { code } = up; + setActivePattern(key); + context.handleUpdate(code, true); + }} + > + {key} + + ))} +
+
- - - + +
- {Object.entries(userPatterns).map(([key, up]) => ( - { - const { code } = up; - setActivePattern(key); - context.handleUpdate(code); - }} - > - {key} - - ))}

Examples

- {Object.entries(tunes).map(([key, tune]) => ( - { - setActivePattern(key); - context.handleUpdate(tune); - }} - > - {key} - - ))} +
+ {Object.entries(tunes).map(([key, tune]) => ( + { + setActivePattern(key); + context.handleUpdate(tune, true); + }} + > + {key} + + ))} +
); } - -/* -selectable examples -if example selected - type character -> create new user pattern with exampleName_n - even if -clicking (+) opens the "new" example with same behavior as above -*/ diff --git a/website/src/repl/panel/SoundsTab.jsx b/website/src/repl/panel/SoundsTab.jsx index aeda4f6d..976361d0 100644 --- a/website/src/repl/panel/SoundsTab.jsx +++ b/website/src/repl/panel/SoundsTab.jsx @@ -5,6 +5,7 @@ import { getAudioContext, soundMap, connectToDestination } from '@strudel.cycles import React, { useMemo, useRef } from 'react'; import { settingsMap, useSettings } from '../../settings.mjs'; import { ButtonGroup } from './Forms.jsx'; +import ImportSoundsButton from './ImportSoundsButton.jsx'; const getSamples = (samples) => Array.isArray(samples) ? samples.length : typeof samples === 'object' ? Object.values(samples).length : 1; @@ -42,8 +43,8 @@ export function SoundsTab() { }); }); return ( -
-
+
+
settingsMap.setKey('soundsFilter', value)} @@ -54,8 +55,9 @@ export function SoundsTab() { user: 'User', }} > + settingsMap.setKey('soundsFilter', 'user')} />
-
+
{soundEntries.map(([name, { data, onTrigger }]) => ( form > * + * { + margin-top: 10px; +} diff --git a/website/src/repl/vanilla/vanilla.mjs b/website/src/repl/vanilla/vanilla.mjs new file mode 100644 index 00000000..488fcf8c --- /dev/null +++ b/website/src/repl/vanilla/vanilla.mjs @@ -0,0 +1,202 @@ +import { logger, getDrawContext, silence, controls, evalScope, hash2code, code2hash } from '@strudel.cycles/core'; +import { StrudelMirror, initTheme, activateTheme } from '@strudel/codemirror'; +import { transpiler } from '@strudel.cycles/transpiler'; +import { + getAudioContext, + webaudioOutput, + registerSynthSounds, + registerZZFXSounds, + samples, +} from '@strudel.cycles/webaudio'; +import './vanilla.css'; + +let editor; +const initialSettings = { + keybindings: 'codemirror', + isLineNumbersDisplayed: true, + isActiveLineHighlighted: true, + isAutoCompletionEnabled: false, + isPatternHighlightingEnabled: true, + isFlashEnabled: true, + isTooltipEnabled: false, + isLineWrappingEnabled: false, + theme: 'teletext', + fontFamily: 'monospace', + fontSize: 18, +}; +initTheme(initialSettings.theme); + +async function run() { + const container = document.getElementById('code'); + if (!container) { + console.warn('could not init: no container found'); + return; + } + + const drawContext = getDrawContext(); + const drawTime = [-2, 2]; + editor = new StrudelMirror({ + defaultOutput: webaudioOutput, + getTime: () => getAudioContext().currentTime, + transpiler, + root: container, + initialCode: '// LOADING', + pattern: silence, + settings: initialSettings, + drawTime, + onDraw: (haps, time, frame, painters) => { + painters.length && drawContext.clearRect(0, 0, drawContext.canvas.width * 2, drawContext.canvas.height * 2); + painters?.forEach((painter) => { + // ctx time haps drawTime paintOptions + painter(drawContext, time, haps, drawTime, { clear: false }); + }); + }, + prebake: async () => { + // populate scope / lazy load modules + const modulesLoading = evalScope( + import('@strudel.cycles/core'), + import('@strudel.cycles/tonal'), + import('@strudel.cycles/mini'), + // import('@strudel.cycles/xen'), + import('@strudel.cycles/webaudio'), + import('@strudel/codemirror'), + /* import('@strudel/hydra'), */ + // import('@strudel.cycles/serial'), + /* import('@strudel.cycles/soundfonts'), */ + // import('@strudel.cycles/csound'), + /* import('@strudel.cycles/midi'), */ + // import('@strudel.cycles/osc'), + controls, // sadly, this cannot be exported from core directly (yet) + ); + // load samples + const ds = 'https://raw.githubusercontent.com/felixroos/dough-samples/main/'; + await Promise.all([ + modulesLoading, + registerSynthSounds(), + registerZZFXSounds(), + samples(`${ds}/tidal-drum-machines.json`), + samples(`${ds}/piano.json`), + samples(`${ds}/Dirt-Samples.json`), + samples(`${ds}/EmuSP12.json`), + samples(`${ds}/vcsl.json`), + ]); + }, + afterEval: ({ code }) => { + window.location.hash = '#' + code2hash(code); + }, + }); + + // init settings + editor.updateSettings(initialSettings); + + logger(`Welcome to Strudel! Click into the editor and then hit ctrl+enter to run the code!`, 'highlight'); + const codeParam = window.location.href.split('#')[1] || ''; + + const initialCode = codeParam + ? hash2code(codeParam) + : `// @date 23-11-30 +// "teigrührgerät" @by froos + +stack( + stack( + s("bd(<3!3 5>,6)/2").bank('RolandTR707') + , + s("~ sd:<0 1>").bank('RolandTR707').room("<0 .5>") + .lastOf(8, x=>x.segment("12").end(.2).gain(isaw)) + , + s("[tb ~ tb]").bank('RolandTR707') + .clip(0).release(.08).room(.2) + ).off(-1/6, x=>x.speed(.7).gain(.2).degrade()) + , + stack( + note(",6) ~!2 [f1?]*2>") + .s("sawtooth").lpf(perlin.range(400,1000)) + .lpa(.1).lpenv(-3).room(.2) + .lpq(8).noise(.2) + .add(note("0,.1")) + , + chord("<~ Gm9 ~!2>") + .dict('ireal').voicing() + .s("sawtooth").vib("2:.1") + .lpf(1000).lpa(.1).lpenv(-4) + .room(.5) + , + n(run(3)).chord("/8") + .dict('ireal-ext') + .off(1/2, add(n(4))) + .voicing() + .clip(.1).release(.05) + .s("sine").jux(rev) + .sometimesBy(sine.slow(16), add(note(12))) + .room(.75) + .lpf(sine.range(200,2000).slow(16)) + .gain(saw.slow(4).div(2)) + ).add(note(perlin.range(0,.5))) +)`; + + editor.setCode(initialCode); // simpler alternative to above init + + // settingsMap.listen((settings, key) => editor.changeSetting(key, settings[key])); + onEvent('strudel-toggle-play', () => editor.toggle()); +} + +run(); + +function onEvent(key, callback) { + const listener = (e) => { + if (e.data === key) { + callback(); + } + }; + window.addEventListener('message', listener); + return () => window.removeEventListener('message', listener); +} + +// settings form +function getInput(form, name) { + return form.querySelector(`input[name=${name}]`) || form.querySelector(`select[name=${name}]`); +} +function getFormValues(form, initial) { + const entries = Object.entries(initial).map(([key, initialValue]) => { + const input = getInput(form, key); + if (!input) { + return [key, initialValue]; // fallback + } + if (input.type === 'checkbox') { + return [key, input.checked]; + } + if (input.type === 'number') { + return [key, Number(input.value)]; + } + if (input.tagName === 'SELECT') { + return [key, input.value]; + } + return [key, input.value]; + }); + return Object.fromEntries(entries); +} +function setFormValues(form, values) { + Object.entries(values).forEach(([key, value]) => { + const input = getInput(form, key); + if (!input) { + return; + } + if (input.type === 'checkbox') { + input.checked = !!value; + } else if (input.type === 'number') { + input.value = value; + } else if (input.tagName) { + input.value = value; + } + }); +} + +const form = document.querySelector('form[name=settings]'); +setFormValues(form, initialSettings); +form.addEventListener('change', () => { + const values = getFormValues(form, initialSettings); + // console.log('values', values); + editor.updateSettings(values); + // TODO: only activateTheme when it changes + activateTheme(values.theme); +}); diff --git a/website/src/settings.mjs b/website/src/settings.mjs index 67d386cf..570b6446 100644 --- a/website/src/settings.mjs +++ b/website/src/settings.mjs @@ -1,7 +1,8 @@ -import { persistentMap } from '@nanostores/persistent'; +import { persistentMap, persistentAtom } from '@nanostores/persistent'; import { useStore } from '@nanostores/react'; import { register } from '@strudel.cycles/core'; import * as tunes from './repl/tunes.mjs'; +import { logger } from '@strudel.cycles/core'; export const defaultSettings = { activeFooter: 'intro', @@ -19,11 +20,28 @@ export const defaultSettings = { soundsFilter: 'all', panelPosition: 'bottom', userPatterns: '{}', - activePattern: '', }; export const settingsMap = persistentMap('strudel-settings', defaultSettings); +// active pattern is separate, because it shouldn't sync state across tabs +// reason: https://github.com/tidalcycles/strudel/issues/857 +const $activePattern = persistentAtom('activePattern', '', { listen: false }); +export function setActivePattern(key) { + $activePattern.set(key); +} +export function getActivePattern() { + return $activePattern.get(); +} +export function useActivePattern() { + return useStore($activePattern); +} +export function initUserCode(code) { + const userPatterns = getUserPatterns(); + const match = Object.entries(userPatterns).find(([_, pat]) => pat.code === code); + setActivePattern(match?.[0] || ''); +} + export function useSettings() { const state = useStore(settingsMap); return { @@ -62,14 +80,14 @@ export const fontSize = patternSetting('fontSize'); export const settingPatterns = { theme, fontFamily, fontSize }; -function getUserPatterns() { +export function getUserPatterns() { return JSON.parse(settingsMap.get().userPatterns); } function getSetting(key) { return settingsMap.get()[key]; } -function setUserPatterns(obj) { +export function setUserPatterns(obj) { settingsMap.setKey('userPatterns', JSON.stringify(obj)); } @@ -116,13 +134,17 @@ export function getUserPattern(key) { } export function renameActivePattern() { - let activePattern = getSetting('activePattern'); + let activePattern = getActivePattern(); let userPatterns = getUserPatterns(); if (!userPatterns[activePattern]) { alert('Cannot rename examples'); return; } const newName = prompt('Enter new name', activePattern); + if (newName === null) { + // canceled + return; + } if (userPatterns[newName]) { alert('Name already taken!'); return; @@ -135,7 +157,7 @@ export function renameActivePattern() { export function updateUserCode(code) { const userPatterns = getUserPatterns(); - let activePattern = getSetting('activePattern'); + let activePattern = getActivePattern(); // check if code is that of an example tune const [example] = Object.entries(tunes).find(([_, tune]) => tune === code) || []; if (example && (!activePattern || activePattern === example)) { @@ -156,7 +178,7 @@ export function updateUserCode(code) { } export function deleteActivePattern() { - let activePattern = getSetting('activePattern'); + let activePattern = getActivePattern(); if (!activePattern) { console.warn('cannot delete: no pattern selected'); return; @@ -174,7 +196,7 @@ export function deleteActivePattern() { } export function duplicateActivePattern() { - let activePattern = getSetting('activePattern'); + let activePattern = getActivePattern(); let latestCode = getSetting('latestCode'); if (!activePattern) { console.warn('cannot duplicate: no pattern selected'); @@ -186,7 +208,31 @@ export function duplicateActivePattern() { setActivePattern(activePattern); } -export function setActivePattern(key) { - console.log('set', key); - settingsMap.setKey('activePattern', key); +export async function importPatterns(fileList) { + const files = Array.from(fileList); + await Promise.all( + files.map(async (file, i) => { + const content = await file.text(); + if (file.type === 'application/json') { + const userPatterns = getUserPatterns() || {}; + setUserPatterns({ ...userPatterns, ...JSON.parse(content) }); + } else if (file.type === 'text/plain') { + const name = file.name.replace(/\.[^/.]+$/, ''); + addUserPattern(name, { code: content }); + } + }), + ); + logger(`import done!`); +} + +export async function exportPatterns() { + const userPatterns = getUserPatterns() || {}; + const blob = new Blob([JSON.stringify(userPatterns)], { type: 'application/json' }); + const downloadLink = document.createElement('a'); + downloadLink.href = window.URL.createObjectURL(blob); + const date = new Date().toISOString().split('T')[0]; + downloadLink.download = `strudel_patterns_${date}.json`; + document.body.appendChild(downloadLink); + downloadLink.click(); + document.body.removeChild(downloadLink); }