Merge pull request #873 from tidalcycles/vanilla-repl-3

main repl vanillification
This commit is contained in:
Felix Roos 2023-12-27 13:17:02 +01:00 committed by GitHub
commit edfa0c65f9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 592 additions and 429 deletions

View File

@ -1,7 +1,7 @@
import { createRoot } from 'react-dom/client';
import jsdoc from '../../doc.json'; import jsdoc from '../../doc.json';
// import { javascriptLanguage } from '@codemirror/lang-javascript'; // import { javascriptLanguage } from '@codemirror/lang-javascript';
import { autocompletion } from '@codemirror/autocomplete'; import { autocompletion } from '@codemirror/autocomplete';
import { h } from './html';
const getDocLabel = (doc) => doc.name || doc.longname; const getDocLabel = (doc) => doc.name || doc.longname;
const getInnerText = (html) => { const getInnerText = (html) => {
@ -10,36 +10,32 @@ const getInnerText = (html) => {
return div.textContent || div.innerText || ''; return div.textContent || div.innerText || '';
}; };
export function Autocomplete({ doc }) { export function Autocomplete({ doc, label }) {
return ( return h`<div class="prose dark:prose-invert max-h-[400px] overflow-auto">
<div className="prose dark:prose-invert max-h-[400px] overflow-auto"> <h1 class="pt-0 mt-0">${label || getDocLabel(doc)}</h1>
<h3 className="pt-0 mt-0">{getDocLabel(doc)}</h3> ${doc.description}
<div dangerouslySetInnerHTML={{ __html: doc.description }} /> <ul>
<ul> ${doc.params?.map(
{doc.params?.map(({ name, type, description }, i) => ( ({ name, type, description }) =>
<li key={i}> `<li>${name} : ${type.names?.join(' | ')} ${description ? ` - ${getInnerText(description)}` : ''}</li>`,
{name} : {type.names?.join(' | ')} {description ? <> - {getInnerText(description)}</> : ''} )}
</li> </ul>
))} <div>
</ul> ${doc.examples?.map((example) => `<div><pre>${example}</pre></div>`)}
<div> </div>
{doc.examples?.map((example, i) => ( </div>`[0];
<div key={i}> /*
<pre <pre
className="cursor-pointer" className="cursor-pointer"
onMouseDown={(e) => { onMouseDown={(e) => {
console.log('ola!'); console.log('ola!');
navigator.clipboard.writeText(example); navigator.clipboard.writeText(example);
e.stopPropagation(); e.stopPropagation();
}} }}
> >
{example} {example}
</pre> </pre>
</div> */
))}
</div>
</div>
);
} }
const jsdocCompletions = jsdoc.docs const jsdocCompletions = jsdoc.docs
@ -54,13 +50,7 @@ const jsdocCompletions = jsdoc.docs
.map((doc) /*: Completion */ => ({ .map((doc) /*: Completion */ => ({
label: getDocLabel(doc), label: getDocLabel(doc),
// detail: 'xxx', // An optional short piece of information to show (with a different style) after the label. // detail: 'xxx', // An optional short piece of information to show (with a different style) after the label.
info: () => { info: () => Autocomplete({ doc }),
const node = document.createElement('div');
// if Autocomplete is non-interactive, it could also be rendered at build time..
// .. using renderToStaticMarkup
createRoot(node).render(<Autocomplete doc={doc} />);
return node;
},
type: 'function', // https://codemirror.net/docs/ref/#autocomplete.Completion.type type: 'function', // https://codemirror.net/docs/ref/#autocomplete.Completion.type
})); }));

View File

@ -6,7 +6,8 @@ import { defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { Compartment, EditorState, Prec } from '@codemirror/state'; import { Compartment, EditorState, Prec } from '@codemirror/state';
import { EditorView, highlightActiveLineGutter, highlightActiveLine, keymap, lineNumbers } from '@codemirror/view'; import { EditorView, highlightActiveLineGutter, highlightActiveLine, keymap, lineNumbers } from '@codemirror/view';
import { Pattern, Drawer, repl, cleanupDraw } from '@strudel.cycles/core'; import { Pattern, Drawer, repl, cleanupDraw } from '@strudel.cycles/core';
// import { isAutoCompletionEnabled } from './Autocomplete'; import { isAutoCompletionEnabled } from './autocomplete.mjs';
import { isTooltipEnabled } from './tooltip.mjs';
import { flash, isFlashEnabled } from './flash.mjs'; import { flash, isFlashEnabled } from './flash.mjs';
import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs'; import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs';
import { keybindings } from './keybindings.mjs'; import { keybindings } from './keybindings.mjs';
@ -18,7 +19,8 @@ const extensions = {
isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []), isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []),
isLineNumbersDisplayed: (on) => (on ? lineNumbers() : []), isLineNumbersDisplayed: (on) => (on ? lineNumbers() : []),
theme, theme,
// isAutoCompletionEnabled, isAutoCompletionEnabled,
isTooltipEnabled,
isPatternHighlightingEnabled, isPatternHighlightingEnabled,
isActiveLineHighlighted: (on) => (on ? [highlightActiveLine(), highlightActiveLineGutter()] : []), isActiveLineHighlighted: (on) => (on ? [highlightActiveLine(), highlightActiveLineGutter()] : []),
isFlashEnabled, isFlashEnabled,
@ -108,7 +110,17 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
export class StrudelMirror { export class StrudelMirror {
constructor(options) { constructor(options) {
const { root, id, initialCode = '', onDraw, drawTime = [-2, 2], autodraw, prebake, ...replOptions } = options; const {
root,
id,
initialCode = '',
onDraw,
drawTime = [0, 0],
autodraw,
prebake,
bgFill = true,
...replOptions
} = options;
this.code = initialCode; this.code = initialCode;
this.root = root; this.root = root;
this.miniLocations = []; this.miniLocations = [];
@ -183,9 +195,15 @@ export class StrudelMirror {
const cmEditor = this.root.querySelector('.cm-editor'); const cmEditor = this.root.querySelector('.cm-editor');
if (cmEditor) { if (cmEditor) {
this.root.style.display = 'block'; this.root.style.display = 'block';
this.root.style.backgroundColor = 'var(--background)'; if (bgFill) {
this.root.style.backgroundColor = 'var(--background)';
}
cmEditor.style.backgroundColor = 'transparent'; cmEditor.style.backgroundColor = 'transparent';
} }
const settings = codemirrorSettings.get();
this.setFontSize(settings.fontSize);
this.setFontFamily(settings.fontFamily);
// stop this repl when another repl is started // stop this repl when another repl is started
this.onStartRepl = (e) => { this.onStartRepl = (e) => {
if (e.detail !== this.id) { if (e.detail !== this.id) {

View File

@ -0,0 +1,17 @@
const parser = new DOMParser();
export let html = (string) => {
return parser.parseFromString(string, 'text/html').querySelectorAll('*');
};
let parseChunk = (chunk) => {
if (Array.isArray(chunk)) return chunk.flat().join('');
if (chunk === undefined) return '';
return chunk;
};
export let h = (strings, ...vars) => {
let string = '';
for (let i in strings) {
string += parseChunk(strings[i]);
string += parseChunk(vars[i]);
}
return html(string);
};

View File

@ -47,7 +47,6 @@
"@strudel.cycles/core": "workspace:*", "@strudel.cycles/core": "workspace:*",
"@uiw/codemirror-themes": "^4.19.16", "@uiw/codemirror-themes": "^4.19.16",
"@uiw/codemirror-themes-all": "^4.19.16", "@uiw/codemirror-themes-all": "^4.19.16",
"react-dom": "^18.2.0",
"nanostores": "^0.8.1", "nanostores": "^0.8.1",
"@nanostores/persistent": "^0.8.0" "@nanostores/persistent": "^0.8.0"
}, },

View File

@ -0,0 +1,76 @@
import { hoverTooltip } from '@codemirror/view';
import jsdoc from '../../doc.json';
import { Autocomplete } from './autocomplete.mjs';
const getDocLabel = (doc) => doc.name || doc.longname;
let ctrlDown = false;
// Record Control key event to trigger or block the tooltip depending on the state
window.addEventListener(
'keyup',
function (e) {
if (e.key == 'Control') {
ctrlDown = false;
}
},
true,
);
window.addEventListener(
'keydown',
function (e) {
if (e.key == 'Control') {
ctrlDown = true;
}
},
true,
);
export const strudelTooltip = hoverTooltip(
(view, pos, side) => {
// Word selection from CodeMirror Hover Tooltip example https://codemirror.net/examples/tooltip/#hover-tooltips
if (!ctrlDown) {
return null;
}
let { from, to, text } = view.state.doc.lineAt(pos);
let start = pos,
end = pos;
while (start > from && /\w/.test(text[start - from - 1])) {
start--;
}
while (end < to && /\w/.test(text[end - from])) {
end++;
}
if ((start == pos && side < 0) || (end == pos && side > 0)) {
return null;
}
let word = text.slice(start - from, end - from);
// Get entry from Strudel documentation
let entry = jsdoc.docs.filter((doc) => getDocLabel(doc) === word)[0];
if (!entry) {
// Try for synonyms
entry = jsdoc.docs.filter((doc) => doc.synonyms && doc.synonyms.includes(word))[0];
if (!entry) {
return null;
}
}
return {
pos: start,
end,
above: false,
arrow: true,
create(view) {
let dom = document.createElement('div');
dom.className = 'strudel-tooltip';
const ac = Autocomplete({ doc: entry, label: word });
dom.appendChild(ac);
return { dom };
},
};
},
{ hoverTime: 10 },
);
export const isTooltipEnabled = (on) => (on ? strudelTooltip : []);

3
pnpm-lock.yaml generated
View File

@ -123,9 +123,6 @@ importers:
nanostores: nanostores:
specifier: ^0.8.1 specifier: ^0.8.1
version: 0.8.1 version: 0.8.1
react-dom:
specifier: ^18.2.0
version: 18.2.0(react@18.2.0)
devDependencies: devDependencies:
vite: vite:
specifier: ^4.3.3 specifier: ^4.3.3

View File

@ -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;
---
<!-- Global Metadata -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<link rel="icon" type="image/svg+xml" href={`${baseNoTrailing}/favicon.ico`} />
<meta
name="description"
content="Strudel is a music live coding environment for the browser, porting the TidalCycles pattern language to JavaScript."
/>
<link rel="icon" href={`${baseNoTrailing}/favicon.ico`} />
<link rel="apple-touch-icon" href={`${baseNoTrailing}/icons/apple-icon-180.png`} sizes="180x180" />
<meta name="theme-color" content="#222222" />
<base href={BASE_URL} />
<!-- Scrollable a11y code helper -->
<script src{`${baseNoTrailing}/make-scrollable-code-focusable.js`} is:inline></script>
<script src="/src/pwa.ts"></script>
<!-- this does not work for some reason: -->
<!-- <style is:global define:vars={strudelTheme}></style> -->
<!-- the following variables are just a fallback to make sure everything is readable without JS -->
<style is:global>
:root {
--background: #222;
--lineBackground: #22222299;
--foreground: #fff;
--caret: #ffcc00;
--selection: rgba(128, 203, 196, 0.5);
--selectionMatch: #036dd626;
--lineHighlight: #00000050;
--gutterBackground: transparent;
--gutterForeground: #8a919966;
}
</style>
{pwaInfo && <Fragment set:html={pwaInfo.webManifest.linkTag} />}
<script>
// https://medium.com/quick-code/100vh-problem-with-ios-safari-92ab23c852a8
const appHeight = () => {
const doc = document.documentElement;
doc.style.setProperty('--app-height', `${window.innerHeight - 1}px`);
};
if (typeof window !== 'undefined') {
window.addEventListener('resize', appHeight);
appHeight();
}
</script>

View File

@ -0,0 +1,14 @@
---
import HeadCommonNext from '../../components/HeadCommonNext.astro';
import { Repl2 } from '../../repl/Repl2';
---
<html lang="en" class="dark">
<head>
<HeadCommonNext />
<title>Strudel REPL</title>
</head>
<body class="h-app-height bg-background">
<Repl2 client:only="react" />
</body>
</html>

View File

@ -9,7 +9,7 @@ The docs page is built ontop of astro's [docs site](https://github.com/withastro
## Adding a new Docs Page ## Adding a new Docs Page
1. add a `.mdx` file in a path under `website/src/pages/`, e.g. [website/src/pages/learn/code.mdx](https://raw.githubusercontent.com/tidalcycles/strudel/main/website/src/pages/learn/code.mdx) will be available under https://strudel.cc/learn/code (or locally under `http://localhost:4321/learn/code`) 1. add a `.mdx` file in a path under `website/src/pages/`, e.g. [website/src/pages/learn/code.mdx](https://raw.githubusercontent.com/tidalcycles/strudel/main/website/src/pages/learn/code.mdx) will be available under https://strudel.cc/learn/code/ (or locally under `http://localhost:4321/learn/code/`)
2. make sure to copy the top part of another existing docs page. Adjust the title accordingly 2. make sure to copy the top part of another existing docs page. Adjust the title accordingly
3. To add a link to the sidebar, add a new entry to `SIDEBAR` to [`config.ts`](https://github.com/tidalcycles/strudel/blob/main/website/src/config.ts) 3. To add a link to the sidebar, add a new entry to `SIDEBAR` to [`config.ts`](https://github.com/tidalcycles/strudel/blob/main/website/src/config.ts)

View File

@ -1,92 +0,0 @@
<html lang="en" class="dark">
<head>
<title>Strudel Vanilla REPL</title>
</head>
<body class="h-app-height">
<div class="settings">
<form name="settings" class="flex flex-col space-y-1 bg-[#00000080]">
<label
>theme
<select name="theme">
<option>strudelTheme</option>
<option>bluescreen</option>
<option>blackscreen</option>
<option>whitescreen</option>
<option>teletext</option>
<option>algoboy</option>
<option>terminal</option>
<option>abcdef</option>
<option>androidstudio</option>
<option>atomone</option>
<option>aura</option>
<option>bespin</option>
<option>darcula</option>
<option>dracula</option>
<option>duotoneDark</option>
<option>eclipse</option>
<option>githubDark</option>
<option>gruvboxDark</option>
<option>materialDark</option>
<option>nord</option>
<option>okaidia</option>
<option>solarizedDark</option>
<option>sublime</option>
<option>tokyoNight</option>
<option>tokyoNightStorm</option>
<option>vscodeDark</option>
<option>xcodeDark</option>
<option>bbedit</option>
<option>duotoneLight</option>
<option>githubLight</option>
<option>gruvboxLight</option>
<option>materialLight</option>
<option>noctisLilac</option>
<option>solarizedLight</option>
<option>tokyoNightDay</option>
<option>xcodeLight</option>
</select> </label
><br />
<label
>keybindings
<select name="keybindings">
<option>codemirror</option>
<option>vim</option>
<option>emacs</option>
<option>vscode</option>
</select> </label
><br />
<label>fontFamily
<select name="fontFamily">
<option value="monospace">monospace</option>
<option value="BigBlueTerminal">BigBlueTerminal</option>
<option value="x3270">x3270</option>
<option value="PressStart">PressStart2P</option>
<option value="galactico">galactico</option>
<option value="we-come-in-peace">we-come-in-peace</option>
<option value="FiraCode">FiraCode</option>
<option value="FiraCode-SemiBold">FiraCode-SemiBold</option>
<option value="teletext">teletext</option>
<option value="mode7">mode7</option>
</select>
</label>
<br />
<label>fontSize <input type="number" name="fontSize" /></label>
<br />
<label><input type="checkbox" name="isLineNumbersDisplayed" />isLineNumbersDisplayed</label>
<br />
<label><input type="checkbox" name="isActiveLineHighlighted" />isActiveLineHighlighted</label>
<br />
<label><input type="checkbox" name="isPatternHighlightingEnabled" />isPatternHighlightingEnabled</label>
<br />
<label><input type="checkbox" name="isFlashEnabled" />isFlashEnabled</label>
<br />
<label><input type="checkbox" name="isLineWrappingEnabled" />isLineWrappingEnabled</label>
<!-- <label><input type="checkbox" name="isAutoCompletionEnabled" />isAutoCompletionEnabled</label> -->
<!-- <label><input type="checkbox" name="isTooltipEnabled" />isTooltipEnabled</label> -->
</form>
</div>
<strudel-editor id="editor"></strudel-editor>
<script src="../../repl/vanilla/vanilla.mjs"></script>
<script src="@strudel/repl"></script>
</body>
</html>

View File

@ -18,7 +18,6 @@ export function Header({ context }) {
started, started,
pending, pending,
isDirty, isDirty,
lastShared,
activeCode, activeCode,
handleTogglePlay, handleTogglePlay,
handleUpdate, handleUpdate,
@ -119,7 +118,7 @@ export function Header({ context }) {
onClick={handleShare} onClick={handleShare}
> >
<LinkIcon className="w-6 h-6" /> <LinkIcon className="w-6 h-6" />
<span>share{lastShared && lastShared === (activeCode || code) ? 'd!' : ''}</span> <span>share</span>
</button> </button>
)} )}
{!isEmbedded && ( {!isEmbedded && (

View File

@ -4,83 +4,33 @@ Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/st
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon';
cleanupDraw, import { cleanupDraw, cleanupUi, code2hash, getDrawContext, logger } from '@strudel.cycles/core';
cleanupUi, import { CodeMirror, cx, flash, useHighlighting, useKeydown, useStrudel } from '@strudel.cycles/react';
controls, import { useWidgets } from '@strudel.cycles/react/src/hooks/useWidgets.mjs';
evalScope,
getDrawContext,
logger,
code2hash,
hash2code,
} from '@strudel.cycles/core';
import { CodeMirror, cx, flash, useHighlighting, useStrudel, useKeydown } from '@strudel.cycles/react';
import { getAudioContext, initAudioOnFirstClick, resetLoadedSounds, webaudioOutput } from '@strudel.cycles/webaudio'; import { getAudioContext, initAudioOnFirstClick, resetLoadedSounds, webaudioOutput } from '@strudel.cycles/webaudio';
import { createClient } from '@supabase/supabase-js'; import { createContext, useCallback, useEffect, useMemo, useState } from 'react';
import { nanoid } from 'nanoid'; import {
import React, { createContext, useCallback, useEffect, useState, useMemo } from 'react'; initUserCode,
setActivePattern,
setLatestCode,
settingsMap,
updateUserCode,
useSettings,
} from '../settings.mjs';
import { Header } from './Header';
import Loader from './Loader';
import './Repl.css'; import './Repl.css';
import { Panel } from './panel/Panel'; import { Panel } from './panel/Panel';
import { Header } from './Header';
import { prebake } from './prebake.mjs'; import { prebake } from './prebake.mjs';
import * as tunes from './tunes.mjs';
import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon';
import { themes } from './themes.mjs'; import { themes } from './themes.mjs';
import { import { getRandomTune, initCode, loadModules, shareCode } from './util.mjs';
settingsMap,
useSettings,
setLatestCode,
updateUserCode,
setActivePattern,
getActivePattern,
getUserPattern,
initUserCode,
} from '../settings.mjs';
import Loader from './Loader';
import { settingPatterns } from '../settings.mjs';
import { isTauri } from '../tauri.mjs';
import { useWidgets } from '@strudel.cycles/react/src/hooks/useWidgets.mjs';
import { writeText } from '@tauri-apps/api/clipboard';
import { registerSamplesFromDB, userSamplesDBConfig } from './idbutils.mjs';
const { latestCode } = settingsMap.get(); const { latestCode } = settingsMap.get();
initAudioOnFirstClick(); initAudioOnFirstClick();
// Create a single supabase client for interacting with your database const modulesLoading = loadModules();
const supabase = createClient(
'https://pidxdsxphlhzjnzmifth.supabase.co',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBpZHhkc3hwaGxoempuem1pZnRoIiwicm9sZSI6ImFub24iLCJpYXQiOjE2NTYyMzA1NTYsImV4cCI6MTk3MTgwNjU1Nn0.bqlw7802fsWRnqU5BLYtmXk_k-D1VFmbkHMywWc15NM',
);
let modules = [
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'),
];
if (isTauri()) {
modules = modules.concat([
import('@strudel/desktopbridge/loggerbridge.mjs'),
import('@strudel/desktopbridge/midibridge.mjs'),
import('@strudel/desktopbridge/oscbridge.mjs'),
]);
} else {
modules = modules.concat([import('@strudel.cycles/midi'), import('@strudel.cycles/osc')]);
}
const modulesLoading = evalScope(
controls, // sadly, this cannot be exported from core direclty
settingPatterns,
...modules,
);
const presets = prebake(); const presets = prebake();
let drawContext, clearCanvas; let drawContext, clearCanvas;
@ -91,43 +41,6 @@ if (typeof window !== 'undefined') {
const getTime = () => getAudioContext().currentTime; const getTime = () => getAudioContext().currentTime;
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(); const { code: randomTune, name } = getRandomTune();
export const ReplContext = createContext(null); export const ReplContext = createContext(null);
@ -135,7 +48,6 @@ export const ReplContext = createContext(null);
export function Repl({ embedded = false }) { export function Repl({ embedded = false }) {
const isEmbedded = embedded || window.location !== window.parent.location; const isEmbedded = embedded || window.location !== window.parent.location;
const [view, setView] = useState(); // codemirror view const [view, setView] = useState(); // codemirror view
const [lastShared, setLastShared] = useState();
const [pending, setPending] = useState(true); const [pending, setPending] = useState(true);
const { const {
theme, theme,
@ -204,7 +116,6 @@ export function Repl({ embedded = false }) {
msg = `A random code snippet named "${name}" has been loaded!`; msg = `A random code snippet named "${name}" has been loaded!`;
} }
//registers samples that have been saved to the index DB //registers samples that have been saved to the index DB
registerSamplesFromDB(userSamplesDBConfig);
logger(`Welcome to Strudel! ${msg} Press play or hit ctrl+enter to run it!`, 'highlight'); logger(`Welcome to Strudel! ${msg} Press play or hit ctrl+enter to run it!`, 'highlight');
setPending(false); setPending(false);
}); });
@ -289,42 +200,13 @@ export function Repl({ embedded = false }) {
await evaluate(code, false); await evaluate(code, false);
}; };
const handleShare = async () => { const handleShare = async () => shareCode(activeCode || code);
const codeToShare = activeCode || code;
if (lastShared === codeToShare) {
logger(`Link already generated!`, 'error');
return;
}
// generate uuid in the browser
const hash = nanoid(12);
const shareUrl = window.location.origin + window.location.pathname + '?' + hash;
const { data, error } = await supabase.from('code').insert([{ code: codeToShare, hash }]);
if (!error) {
setLastShared(activeCode || code);
// copy shareUrl to clipboard
if (isTauri()) {
await writeText(shareUrl);
} else {
await navigator.clipboard.writeText(shareUrl);
}
const message = `Link copied to clipboard: ${shareUrl}`;
alert(message);
// alert(message);
logger(message, 'highlight');
} else {
console.log('error', error);
const message = `Error: ${error.message}`;
// alert(message);
logger(message);
}
};
const context = { const context = {
scheduler, scheduler,
embedded, embedded,
started, started,
pending, pending,
isDirty, isDirty,
lastShared,
activeCode, activeCode,
handleChangeCode, handleChangeCode,
handleTogglePlay, handleTogglePlay,

234
website/src/repl/Repl2.jsx Normal file
View File

@ -0,0 +1,234 @@
/*
App.js - <short description TODO>
Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/repl/src/App.js>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { code2hash, getDrawContext, logger, silence } from '@strudel.cycles/core';
import { cx } from '@strudel.cycles/react';
import { transpiler } from '@strudel.cycles/transpiler';
import { getAudioContext, initAudioOnFirstClick, webaudioOutput } from '@strudel.cycles/webaudio';
import { StrudelMirror, defaultSettings } from '@strudel/codemirror';
/* import { writeText } from '@tauri-apps/api/clipboard';
import { nanoid } from 'nanoid'; */
import { createContext, useCallback, useEffect, useRef, useState } from 'react';
import {
initUserCode,
setActivePattern,
setLatestCode,
settingsMap,
updateUserCode,
useSettings,
} from '../settings.mjs';
import { Header } from './Header';
import Loader from './Loader';
import './Repl.css';
import { Panel } from './panel/Panel';
// import { prebake } from '@strudel/repl';
import { useStore } from '@nanostores/react';
import { prebake /* , resetSounds */ } from './prebake.mjs';
import { getRandomTune, initCode, loadModules, shareCode } from './util.mjs';
import './Repl.css';
const { code: randomTune, name } = getRandomTune();
export const ReplContext = createContext(null);
const { latestCode } = settingsMap.get();
initAudioOnFirstClick();
const modulesLoading = loadModules();
const presets = prebake();
let drawContext, clearCanvas;
if (typeof window !== 'undefined') {
drawContext = getDrawContext();
clearCanvas = () => drawContext.clearRect(0, 0, drawContext.canvas.height, drawContext.canvas.width);
}
// const getTime = () => getAudioContext().currentTime;
export function Repl2({ embedded = false }) {
//const isEmbedded = embedded || window.location !== window.parent.location;
const isEmbedded = false;
const { panelPosition, isZen } = useSettings();
/* const replState = useStore($replstate);
const isDirty = useStore($repldirty); */
const shouldDraw = true;
const init = useCallback(({ shouldDraw }) => {
// TODO: find way to make spiral & punchcard work (if there's any)
// upping the 2nd value leads to slow eval times
// because Drawer.invalidate might query alot at one time
const drawTime = [0, 0];
const drawContext = shouldDraw ? getDrawContext() : null;
let onDraw;
if (shouldDraw) {
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 });
});
};
}
const editor = new StrudelMirror({
defaultOutput: webaudioOutput,
getTime: () => getAudioContext().currentTime,
transpiler,
autodraw: false,
root: containerRef.current,
initialCode: '// LOADING',
pattern: silence,
drawTime,
onDraw,
prebake: async () => Promise.all([modulesLoading, presets]),
onUpdateState: (state) => {
setReplState({ ...state });
},
afterEval: ({ code }) => {
updateUserCode(code);
// setPending(false);
setLatestCode(code);
window.location.hash = '#' + code2hash(code);
},
bgFill: false,
});
// init settings
initCode().then((decoded) => {
let msg;
if (decoded) {
editor.setCode(decoded);
initUserCode(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!`;
}
logger(`Welcome to Strudel! ${msg} Press play or hit ctrl+enter to run it!`, 'highlight');
// setPending(false);
});
editorRef.current = editor;
}, []);
const [replState, setReplState] = useState({});
const { started, isDirty, error, activeCode } = replState;
const editorRef = useRef();
const containerRef = useRef();
const [client, setClient] = useState(false);
useEffect(() => {
setClient(true);
if (!editorRef.current) {
setTimeout(() => {
init({ shouldDraw });
});
}
return () => {
editorRef.current?.clear();
delete editorRef.current;
};
}, []);
// this can be simplified once SettingsTab has been refactored to change codemirrorSettings directly!
// this will be the case when the main repl is being replaced
const _settings = useStore(settingsMap, { keys: Object.keys(defaultSettings) });
useEffect(() => {
let editorSettings = {};
Object.keys(defaultSettings).forEach((key) => {
if (_settings.hasOwnProperty(key)) {
editorSettings[key] = _settings[key];
}
});
editorRef.current?.updateSettings(editorSettings);
}, [_settings]);
//
// UI Actions
//
const handleTogglePlay = async () => editorRef.current?.toggle();
const handleUpdate = async (newCode, reset = false) => {
if (reset) {
clearCanvas();
resetLoadedSounds();
editorRef.current.repl.setCps(1);
await prebake(); // declare default samples
}
if (newCode || isDirty) {
editorRef.current.setCode(newCode);
editorRef.current.repl.evaluate(newCode);
}
logger('[repl] code updated!');
};
const handleShuffle = async () => {
// window.postMessage('strudel-shuffle');
const { code, name } = getRandomTune();
logger(`[repl] ✨ loading random tune "${name}"`);
setActivePattern(name);
clearCanvas();
resetLoadedSounds();
editorRef.current.repl.setCps(1);
await prebake(); // declare default samples
editorRef.current.setCode(code);
editorRef.current.repl.evaluate(code);
};
const handleShare = async () => shareCode(activeCode);
const pending = false;
//const error = undefined;
// const { started, activeCode } = replState;
const context = {
// scheduler,
embedded,
started,
pending,
isDirty,
activeCode,
handleTogglePlay,
handleUpdate,
handleShuffle,
handleShare,
};
return (
// bg-gradient-to-t from-blue-900 to-slate-900
// bg-gradient-to-t from-green-900 to-slate-900
<ReplContext.Provider value={context}>
<div
className={cx(
'h-full flex flex-col relative',
// overflow-hidden
)}
>
<Loader active={pending} />
<Header context={context} />
{/* isEmbedded && !started && (
<button
onClick={() => handleTogglePlay()}
className="text-white text-2xl fixed left-[50%] top-[50%] translate-x-[-50%] translate-y-[-50%] z-[1000] m-auto p-4 bg-black rounded-md flex items-center space-x-2"
>
<PlayCircleIcon className="w-6 h-6" />
<span>play</span>
</button>
) */}
<div className="grow flex relative overflow-hidden">
<section
className={'text-gray-100 cursor-text pb-0 overflow-auto grow' + (isZen ? ' px-10' : '')}
id="code"
ref={containerRef}
></section>
{panelPosition === 'right' && !isEmbedded && <Panel context={context} />}
</div>
{error && (
<div className="text-red-500 p-4 bg-lineHighlight animate-pulse">{error.message || 'Unknown Error :-/'}</div>
)}
{panelPosition === 'bottom' && !isEmbedded && <Panel context={context} />}
</div>
</ReplContext.Provider>
);
}

View File

@ -24,7 +24,7 @@ const clearIDB = () => {
}; };
// queries the DB, and registers the sounds so they can be played // queries the DB, and registers the sounds so they can be played
export const registerSamplesFromDB = (config, onComplete = () => {}) => { export const registerSamplesFromDB = (config = userSamplesDBConfig, onComplete = () => {}) => {
openDB(config, (objectStore) => { openDB(config, (objectStore) => {
let query = objectStore.getAll(); let query = objectStore.getAll();
query.onsuccess = (event) => { query.onsuccess = (event) => {

View File

@ -78,6 +78,7 @@ export function SettingsTab() {
theme, theme,
keybindings, keybindings,
isLineNumbersDisplayed, isLineNumbersDisplayed,
isPatternHighlightingEnabled,
isActiveLineHighlighted, isActiveLineHighlighted,
isAutoCompletionEnabled, isAutoCompletionEnabled,
isTooltipEnabled, isTooltipEnabled,
@ -153,6 +154,11 @@ export function SettingsTab() {
onChange={(cbEvent) => settingsMap.setKey('isActiveLineHighlighted', cbEvent.target.checked)} onChange={(cbEvent) => settingsMap.setKey('isActiveLineHighlighted', cbEvent.target.checked)}
value={isActiveLineHighlighted} value={isActiveLineHighlighted}
/> />
<Checkbox
label="Highlight events in code"
onChange={(cbEvent) => settingsMap.setKey('isPatternHighlightingEnabled', cbEvent.target.checked)}
value={isPatternHighlightingEnabled}
/>
<Checkbox <Checkbox
label="Enable auto-completion" label="Enable auto-completion"
onChange={(cbEvent) => settingsMap.setKey('isAutoCompletionEnabled', cbEvent.target.checked)} onChange={(cbEvent) => settingsMap.setKey('isAutoCompletionEnabled', cbEvent.target.checked)}

View File

@ -1,5 +1,6 @@
import { Pattern, noteToMidi, valueToMidi } from '@strudel.cycles/core'; import { Pattern, noteToMidi, valueToMidi } from '@strudel.cycles/core';
import { registerSynthSounds, registerZZFXSounds, samples } from '@strudel.cycles/webaudio'; import { registerSynthSounds, registerZZFXSounds, samples } from '@strudel.cycles/webaudio';
import { registerSamplesFromDB } from './idbutils.mjs';
import './piano.mjs'; import './piano.mjs';
import './files.mjs'; import './files.mjs';
@ -12,6 +13,7 @@ export async function prebake() {
await Promise.all([ await Promise.all([
registerSynthSounds(), registerSynthSounds(),
registerZZFXSounds(), registerZZFXSounds(),
registerSamplesFromDB(),
//registerSoundfonts(), //registerSoundfonts(),
// need dynamic import here, because importing @strudel.cycles/soundfonts fails on server: // need dynamic import here, because importing @strudel.cycles/soundfonts fails on server:
// => getting "window is not defined", as soon as "@strudel.cycles/soundfonts" is imported statically // => getting "window is not defined", as soon as "@strudel.cycles/soundfonts" is imported statically

112
website/src/repl/util.mjs Normal file
View File

@ -0,0 +1,112 @@
import { controls, evalScope, hash2code, logger } from '@strudel.cycles/core';
import { settingPatterns } from '../settings.mjs';
import { isTauri } from '../tauri.mjs';
import './Repl.css';
import * as tunes from './tunes.mjs';
import { createClient } from '@supabase/supabase-js';
import { nanoid } from 'nanoid';
import { writeText } from '@tauri-apps/api/clipboard';
// Create a single supabase client for interacting with your database
const supabase = createClient(
'https://pidxdsxphlhzjnzmifth.supabase.co',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBpZHhkc3hwaGxoempuem1pZnRoIiwicm9sZSI6ImFub24iLCJpYXQiOjE2NTYyMzA1NTYsImV4cCI6MTk3MTgwNjU1Nn0.bqlw7802fsWRnqU5BLYtmXk_k-D1VFmbkHMywWc15NM',
);
export 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);
}
}
export 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 };
}
export function loadModules() {
let modules = [
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'),
];
if (isTauri()) {
modules = modules.concat([
import('@strudel/desktopbridge/loggerbridge.mjs'),
import('@strudel/desktopbridge/midibridge.mjs'),
import('@strudel/desktopbridge/oscbridge.mjs'),
]);
} else {
modules = modules.concat([import('@strudel.cycles/midi'), import('@strudel.cycles/osc')]);
}
return evalScope(
controls, // sadly, this cannot be exported from core direclty
settingPatterns,
...modules,
);
}
let lastShared;
export async function shareCode(codeToShare) {
// const codeToShare = activeCode || code;
if (lastShared === codeToShare) {
logger(`Link already generated!`, 'error');
return;
}
// generate uuid in the browser
const hash = nanoid(12);
const shareUrl = window.location.origin + window.location.pathname + '?' + hash;
const { data, error } = await supabase.from('code').insert([{ code: codeToShare, hash }]);
if (!error) {
lastShared = codeToShare;
// copy shareUrl to clipboard
if (isTauri()) {
await writeText(shareUrl);
} else {
await navigator.clipboard.writeText(shareUrl);
}
const message = `Link copied to clipboard: ${shareUrl}`;
alert(message);
// alert(message);
logger(message, 'highlight');
} else {
console.log('error', error);
const message = `Error: ${error.message}`;
// alert(message);
logger(message);
}
}

View File

@ -1,34 +0,0 @@
body,
input {
font-family: monospace;
background-color: black;
color: white;
}
input,
select {
background-color: black !important;
}
html,
body,
#editor,
.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;
}

View File

@ -1,117 +0,0 @@
import { hash2code, logger } from '@strudel.cycles/core';
import { codemirrorSettings, defaultSettings } from '@strudel/codemirror';
import './vanilla.css';
let editor;
async function run() {
const repl = document.getElementById('editor');
editor = repl.editor;
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("<g1(<3 4>,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("<Gm9 Gm11>/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, codemirrorSettings.get());
form.addEventListener('change', () => {
const values = getFormValues(form, defaultSettings);
editor?.updateSettings(values);
});

View File

@ -12,6 +12,7 @@ export const defaultSettings = {
isAutoCompletionEnabled: false, isAutoCompletionEnabled: false,
isTooltipEnabled: false, isTooltipEnabled: false,
isLineWrappingEnabled: false, isLineWrappingEnabled: false,
isPatternHighlightingEnabled: true,
theme: 'strudelTheme', theme: 'strudelTheme',
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: 18, fontSize: 18,
@ -50,6 +51,7 @@ export function useSettings() {
isLineNumbersDisplayed: [true, 'true'].includes(state.isLineNumbersDisplayed) ? true : false, isLineNumbersDisplayed: [true, 'true'].includes(state.isLineNumbersDisplayed) ? true : false,
isActiveLineHighlighted: [true, 'true'].includes(state.isActiveLineHighlighted) ? true : false, isActiveLineHighlighted: [true, 'true'].includes(state.isActiveLineHighlighted) ? true : false,
isAutoCompletionEnabled: [true, 'true'].includes(state.isAutoCompletionEnabled) ? true : false, isAutoCompletionEnabled: [true, 'true'].includes(state.isAutoCompletionEnabled) ? true : false,
isPatternHighlightingEnabled: [true, 'true'].includes(state.isPatternHighlightingEnabled) ? true : false,
isTooltipEnabled: [true, 'true'].includes(state.isTooltipEnabled) ? true : false, isTooltipEnabled: [true, 'true'].includes(state.isTooltipEnabled) ? true : false,
isLineWrappingEnabled: [true, 'true'].includes(state.isLineWrappingEnabled) ? true : false, isLineWrappingEnabled: [true, 'true'].includes(state.isLineWrappingEnabled) ? true : false,
fontSize: Number(state.fontSize), fontSize: Number(state.fontSize),