diff --git a/packages/codemirror/codemirror.mjs b/packages/codemirror/codemirror.mjs index 2094416ea..6c89a2053 100644 --- a/packages/codemirror/codemirror.mjs +++ b/packages/codemirror/codemirror.mjs @@ -1,19 +1,21 @@ import { defaultKeymap } from '@codemirror/commands'; import { javascript } from '@codemirror/lang-javascript'; import { defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'; -import { EditorState } from '@codemirror/state'; +import { EditorState, Compartment } 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 { highlightExtension, highlightMiniLocations, updateMiniLocations } from './highlight.mjs'; import { oneDark } from './themes/one-dark'; +const themeComparment = new Compartment(); + // https://codemirror.net/docs/guide/ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, theme = oneDark, root }) { let state = EditorState.create({ doc: initialCode, extensions: [ - theme, + themeComparment.of(theme), javascript(), lineNumbers(), highlightExtension, @@ -43,7 +45,7 @@ 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, theme, ...replOptions } = options; this.code = initialCode; this.drawer = new Drawer((haps, time) => { @@ -81,12 +83,14 @@ export class StrudelMirror { await prebaked; }, afterEval: (options) => { + updateMiniLocations(this.editor, options.meta?.miniLocations); replOptions?.afterEval?.(options); this.drawer.invalidate(); }, }); this.editor = initEditor({ root, + theme, initialCode, onChange: (v) => { this.code = v.state.doc.toString(); @@ -108,6 +112,15 @@ export class StrudelMirror { flash(this.editor, ms); } highlight(haps, time) { - highlightMiniLocations(this.editor.view, time, haps); + highlightMiniLocations(this.editor, time, haps); + } + setTheme(theme) { + this.editor.dispatch({ + effects: themeComparment.reconfigure(theme), + }); + } + setCode(code) { + const changes = { from: 0, to: this.editor.state.doc.length, insert: code }; + this.editor.dispatch({ changes }); } } diff --git a/packages/core/package.json b/packages/core/package.json index 5a626b0ca..07acd0350 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -31,7 +31,8 @@ }, "homepage": "https://strudel.cc", "dependencies": { - "fraction.js": "^4.2.0" + "fraction.js": "^4.2.0", + "nanostores": "^0.8.1" }, "gitHead": "0e26d4e741500f5bae35b023608f062a794905c2", "devDependencies": { diff --git a/packages/core/repl.mjs b/packages/core/repl.mjs index 67e2812d0..b11cb5933 100644 --- a/packages/core/repl.mjs +++ b/packages/core/repl.mjs @@ -4,6 +4,19 @@ import { logger } from './logger.mjs'; import { setTime } from './time.mjs'; import { evalScope } from './evaluate.mjs'; import { register } from './pattern.mjs'; +import { atom } from 'nanostores'; + +export const $replstate = atom({ + schedulerError: undefined, + evalError: undefined, + code: '// LOADING', + activeCode: '// LOADING', + pattern: undefined, + miniLocations: [], + widgets: [], + pending: true, +}); +export const setReplState = (key, value) => $replstate.set({ ...$replstate.get(), [key]: value }); export function repl({ interval, @@ -22,7 +35,10 @@ export function repl({ onTrigger: getTrigger({ defaultOutput, getTime }), onError: onSchedulerError, getTime, - onToggle, + onToggle: (started) => { + setReplState('started', started); + onToggle?.(started); + }, }); let playPatterns = []; const setPattern = (pattern, autostart = true) => { @@ -35,6 +51,8 @@ export function repl({ throw new Error('no code to evaluate'); } try { + setReplState('code', code); + setReplState('pending', true); await beforeEval?.({ code }); playPatterns = []; let { pattern, meta } = await _evaluate(code, transpiler); @@ -43,11 +61,20 @@ export function repl({ } logger(`[eval] code updated`); setPattern(pattern, autostart); + setReplState('miniLocations', meta?.miniLocations || []); + setReplState('widgets', meta?.widgets || []); + setReplState('activeCode', code); + setReplState('pattern', pattern); + setReplState('evalError', undefined); + setReplState('schedulerError', undefined); + setReplState('pending', false); afterEval?.({ code, pattern, meta }); return pattern; } catch (err) { // console.warn(`[repl] eval error: ${err.message}`); logger(`[eval] error: ${err.message}`, 'error'); + setReplState('evalError', err); + setReplState('pending', false); onEvalError?.(err); } }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98f6441fe..e436542cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,6 +102,9 @@ importers: fraction.js: specifier: ^4.2.0 version: 4.2.0 + nanostores: + specifier: ^0.8.1 + version: 0.8.1 devDependencies: vite: specifier: ^4.3.3 diff --git a/website/src/docs/MiniRepl.jsx b/website/src/docs/MiniRepl.jsx index db90cb8dd..552e1027e 100644 --- a/website/src/docs/MiniRepl.jsx +++ b/website/src/docs/MiniRepl.jsx @@ -28,7 +28,7 @@ export function MiniRepl({ useEffect(() => { // we have to load this package on the client // because codemirror throws an error on the server - Promise.all([import('@strudel.cycles/react')]) + Promise.all([import('@strudel.cycles/react'), init]) .then(([res]) => setRepl(() => res.MiniRepl)) .catch((err) => console.error(err)); }, []); diff --git a/website/src/pages/vanilla.astro b/website/src/pages/vanilla.astro new file mode 100644 index 000000000..1077892d5 --- /dev/null +++ b/website/src/pages/vanilla.astro @@ -0,0 +1,14 @@ +--- +import HeadCommon from '../components/HeadCommon.astro'; +import VanillaRepl from '../repl/VanillaRepl.astro'; +--- + + + + + Strudel Vanilla REPL + + + + + diff --git a/website/src/repl/Repl.jsx b/website/src/repl/Repl.jsx index 98d083eea..a4a70319d 100644 --- a/website/src/repl/Repl.jsx +++ b/website/src/repl/Repl.jsx @@ -5,32 +5,20 @@ This program is free software: you can redistribute it and/or modify it under th */ import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon'; -import { getDrawContext, logger } from '@strudel.cycles/core'; -import { CodeMirror, cx } from '@strudel.cycles/react'; -import { getAudioContext, resetLoadedSounds } from '@strudel.cycles/webaudio'; -import { createClient } from '@supabase/supabase-js'; +import { getDrawContext, logger, $replstate } from '@strudel.cycles/core'; +import { cx } from '@strudel.cycles/react'; +import { getAudioContext } from '@strudel.cycles/webaudio'; import { writeText } from '@tauri-apps/api/clipboard'; import { nanoid } from 'nanoid'; -import { createContext, useEffect, useMemo, useState } from 'react'; -import { settingsMap, useSettings } from '../settings.mjs'; +import { createContext, useState } from 'react'; +import { useSettings } from '../settings.mjs'; import { isTauri } from '../tauri.mjs'; import { Footer } from './Footer'; import { Header } from './Header'; import Loader from './Loader'; import './Repl.css'; -import { hash2code, code2hash } from './helpers.mjs'; -import * as tunes from './tunes.mjs'; -import { useRepl } from './useRepl'; -import { setLatestCode } from '../settings.mjs'; import { resetSounds } from './prebake.mjs'; - -const { latestCode } = settingsMap.get(); - -// Create a single supabase client for interacting with your database -const supabase = createClient( - 'https://pidxdsxphlhzjnzmifth.supabase.co', - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBpZHhkc3hwaGxoempuem1pZnRoIiwicm9sZSI6ImFub24iLCJpYXQiOjE2NTYyMzA1NTYsImV4cCI6MTk3MTgwNjU1Nn0.bqlw7802fsWRnqU5BLYtmXk_k-D1VFmbkHMywWc15NM', -); +import { useStore } from '@nanostores/react'; let clearCanvas; if (typeof window !== 'undefined') { @@ -38,106 +26,30 @@ if (typeof window !== 'undefined') { clearCanvas = () => drawContext.clearRect(0, 0, drawContext.canvas.height, drawContext.canvas.width); } -async function initCode() { - // load code from url hash (either short hash from database or decode long hash) - try { - const initialUrl = window.location.href; - const hash = initialUrl.split('?')[1]?.split('#')?.[0]; - const codeParam = window.location.href.split('#')[1] || ''; - // looking like https://strudel.cc/?J01s5i1J0200 (fixed hash length) - if (codeParam) { - // looking like https://strudel.cc/#ImMzIGUzIg%3D%3D (hash length depends on code length) - return hash2code(codeParam); - } else if (hash) { - return supabase - .from('code') - .select('code') - .eq('hash', hash) - .then(({ data, error }) => { - if (error) { - console.warn('failed to load hash', err); - } - if (data.length) { - //console.log('load hash from database', hash); - return data[0].code; - } - }); - } - } catch (err) { - console.warn('failed to decode', err); - } -} - -function getRandomTune() { - const allTunes = Object.entries(tunes); - const randomItem = (arr) => arr[Math.floor(Math.random() * arr.length)]; - const [name, code] = randomItem(allTunes); - return { name, code }; -} - -const { code: randomTune, name } = getRandomTune(); - export const ReplContext = createContext(null); export function Repl({ embedded = false }) { - const isEmbedded = embedded || window.location !== window.parent.location; + //const isEmbedded = embedded || window.location !== window.parent.location; + const isEmbedded = false; const [lastShared, setLastShared] = useState(); const { panelPosition, isZen } = useSettings(); - - const { - codemirror, - code, - setCode, - scheduler, - evaluate, - activateCode, - isDirty, - activeCode, - pattern, - started, - stop, - error, - pending, - setPending, - } = useRepl({ - afterEval: ({ code }) => { - setLatestCode(code); - window.location.hash = '#' + code2hash(code); - }, - }); - - // init code - useEffect(() => { - initCode().then((decoded) => { - let msg; - if (decoded) { - setCode(decoded); - msg = `I have loaded the code from the URL.`; - } else if (latestCode) { - setCode(latestCode); - msg = `Your last session has been loaded!`; - } /* if(randomTune) */ else { - setCode(randomTune); - msg = `A random code snippet named "${name}" has been loaded!`; - } - logger(`Welcome to Strudel! ${msg} Press play or hit ctrl+enter to run it!`, 'highlight'); - setPending(false); - }); - }, []); + const replState = useStore($replstate); // // UI Actions // const handleTogglePlay = async () => { - await getAudioContext().resume(); // fixes no sound in ios webkit + console.log('toggle.'); + window.postMessage('strudel-toggle-play'); + /* await getAudioContext().resume(); // fixes no sound in ios webkit if (!started) { logger('[repl] started. tip: you can also start by pressing ctrl+enter', 'highlight'); activateCode(); } else { logger('[repl] stopped. tip: you can also stop by pressing ctrl+dot', 'highlight'); stop(); - } + } */ }; const handleUpdate = () => { isDirty && activateCode(); @@ -149,7 +61,7 @@ export function Repl({ embedded = false }) { logger(`[repl] ✨ loading random tune "${name}"`); clearCanvas(); await resetSounds(); - scheduler.setCps(1); + // scheduler.setCps(1); await evaluate(code, false); }; @@ -182,15 +94,18 @@ export function Repl({ embedded = false }) { logger(message); } }; + const pending = false; + const error = undefined; + const { started, activeCode } = replState; const context = { - scheduler, + // scheduler, embedded, started, pending, - isDirty, + isDirty: false, lastShared, activeCode, - handleChangeCode: codemirror.handleChangeCode, + // handleChangeCode: codemirror.handleChangeCode, handleTogglePlay, handleUpdate, handleShuffle, @@ -209,7 +124,7 @@ export function Repl({ embedded = false }) { >
- {isEmbedded && !started && ( + {/* isEmbedded && !started && ( - )} + ) */}
-
- +
{ + window.postMessage('strudel-container'); + }} + > + {/* */}
{panelPosition === 'right' && !isEmbedded &&
}
diff --git a/website/src/repl/VanillaRepl.astro b/website/src/repl/VanillaRepl.astro new file mode 100644 index 000000000..34ba9c5ec --- /dev/null +++ b/website/src/repl/VanillaRepl.astro @@ -0,0 +1,15 @@ +--- +import HeadCommon from '../components/HeadCommon.astro'; +import { Repl } from '../repl/Repl.jsx'; +--- + + + + + Strudel REPL + + + + + + diff --git a/website/src/repl/prebake.mjs b/website/src/repl/prebake.mjs index b9fd48792..863a609ce 100644 --- a/website/src/repl/prebake.mjs +++ b/website/src/repl/prebake.mjs @@ -150,7 +150,7 @@ export async function prebake() { ...modules, ); // register sounds and samples - return Promise.all([initAudio, modulesLoading, registerStockSounds()]); + return Promise.all([/* initAudio, */ modulesLoading, registerStockSounds()]); // await samples('github:tidalcycles/Dirt-Samples/master'); } diff --git a/website/src/repl/vanillarepl.mjs b/website/src/repl/vanillarepl.mjs new file mode 100644 index 000000000..b73f24d4a --- /dev/null +++ b/website/src/repl/vanillarepl.mjs @@ -0,0 +1,155 @@ +import { StrudelMirror } from '@strudel/codemirror'; +import { getAudioContext, webaudioOutput } from '@strudel.cycles/webaudio'; +import { transpiler } from '@strudel.cycles/transpiler'; +import { prebake } from './prebake.mjs'; +import { settingsMap } from '@src/settings.mjs'; +import { themes } from '@src/repl/themes.mjs'; +import { setLatestCode } from '../settings.mjs'; +import { hash2code, code2hash } from './helpers.mjs'; +import { createClient } from '@supabase/supabase-js'; +import * as tunes from './tunes.mjs'; + +const onEvent = (key, callback) => { + const listener = (e) => { + if (e.data === key) { + callback(); + } + }; + window.addEventListener('message', listener); + return () => window.removeEventListener('message', listener); +}; + +function run() { + const { latestCode } = settingsMap.get(); + + /* let clearCanvas; +if (typeof window !== 'undefined') { + const drawContext = getDrawContext(); + clearCanvas = () => drawContext.clearRect(0, 0, drawContext.canvas.height, drawContext.canvas.width); +} */ + + // Create a single supabase client for interacting with your database + const supabase = createClient( + 'https://pidxdsxphlhzjnzmifth.supabase.co', + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBpZHhkc3hwaGxoempuem1pZnRoIiwicm9sZSI6ImFub24iLCJpYXQiOjE2NTYyMzA1NTYsImV4cCI6MTk3MTgwNjU1Nn0.bqlw7802fsWRnqU5BLYtmXk_k-D1VFmbkHMywWc15NM', + ); + async function initCode() { + // load code from url hash (either short hash from database or decode long hash) + try { + const initialUrl = window.location.href; + const hash = initialUrl.split('?')[1]?.split('#')?.[0]; + const codeParam = window.location.href.split('#')[1] || ''; + // looking like https://strudel.cc/?J01s5i1J0200 (fixed hash length) + if (codeParam) { + // looking like https://strudel.cc/#ImMzIGUzIg%3D%3D (hash length depends on code length) + return hash2code(codeParam); + } else if (hash) { + return supabase + .from('code') + .select('code') + .eq('hash', hash) + .then(({ data, error }) => { + if (error) { + console.warn('failed to load hash', error); + } + if (data.length) { + //console.log('load hash from database', hash); + return data[0].code; + } + }); + } + } catch (err) { + console.warn('failed to decode', err); + } + } + + const container = document.getElementById('code'); + const setFontSize = (size) => (container.style.fontSize = size + 'px'); + const setFontFamily = (family) => (container.style.fontFamily = family); + + /* const drawContext = getDrawContext() +const drawTime = [-2, 2]; */ + console.log('container', container); + const editor = new StrudelMirror({ + theme: themes.strudelTheme, + defaultOutput: webaudioOutput, + getTime: () => getAudioContext().currentTime, + transpiler, + root: container, + initialCode: '// LOADING', + /* drawTime, + onDraw: (haps, time) => + drawPianoroll({ haps, time, ctx: drawContext, drawTime, fold: 1 }), */ + prebake: () => prebake(), + afterEval: ({ code }) => { + setLatestCode(code); + window.location.hash = '#' + code2hash(code); + }, + }); + + function getRandomTune() { + const allTunes = Object.entries(tunes); + const randomItem = (arr) => arr[Math.floor(Math.random() * arr.length)]; + const [name, code] = randomItem(allTunes); + return { name, code }; + } + + const { code: randomTune, name } = getRandomTune(); + + function init() { + if (!container) { + console.warn('could not init: no container found'); + return; + } + const settings = settingsMap.get(); + setFontSize(settings.fontSize); + setFontFamily(settings.fontFamily); + initCode().then((decoded) => { + let msg; + if (decoded) { + editor.setCode(decoded); + msg = `I have loaded the code from the URL.`; + } else if (latestCode) { + editor.setCode(latestCode); + msg = `Your last session has been loaded!`; + } /* if(randomTune) */ else { + editor.setCode(randomTune); + msg = `A random code snippet named "${name}" has been loaded!`; + } + console.log('msg', msg); + /* logger(`Welcome to Strudel! ${msg} Press play or hit ctrl+enter to run it!`, 'highlight'); + setPending(false); */ + }); + + editor.setTheme(themes[settings.theme || 'strudelTheme']); + } + init(); + + settingsMap.listen((settings, key) => { + const value = settings[key]; + if (key === 'theme') { + editor.setTheme(themes[value]); + } else if (key === 'fontFamily') { + // console.log('change fontFamily', value); + setFontFamily(value); + } else if (key === 'fontSize') { + // console.log('change fontSize', value); + setFontSize(value); + } + }); + + onEvent('strudel-toggle-play', () => { + console.log('toggle-play'); + editor.evaluate(); + }); + + // const isEmbedded = embedded || window.location !== window.parent.location; +} + +let inited = false; +onEvent('strudel-container', () => { + if (!inited) { + inited = true; + run(); + } +});