diff --git a/packages/react/.gitignore b/packages/react/.gitignore
new file mode 100644
index 00000000..a547bf36
--- /dev/null
+++ b/packages/react/.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/react/README.md b/packages/react/README.md
new file mode 100644
index 00000000..7933023d
--- /dev/null
+++ b/packages/react/README.md
@@ -0,0 +1,2 @@
+- use react 17
+- make sure @codemirror/state is installed once (single version)
\ No newline at end of file
diff --git a/packages/react/index.html b/packages/react/index.html
new file mode 100644
index 00000000..b46ab833
--- /dev/null
+++ b/packages/react/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite App
+
+
+
+
+
+
diff --git a/packages/react/package.json b/packages/react/package.json
new file mode 100644
index 00000000..f881c4f9
--- /dev/null
+++ b/packages/react/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "@strudel.cycles/react",
+ "private": true,
+ "version": "0.0.0",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@codemirror/lang-javascript": "^0.19.0",
+ "react": "^17.0.2",
+ "react-codemirror6": "^1.1.0",
+ "react-dom": "^17.0.2",
+ "react-hook-inview": "^4.5.0"
+ },
+ "devDependencies": {
+ "@types/react": "^17.0.2",
+ "@types/react-dom": "^17.0.2",
+ "@vitejs/plugin-react": "^1.3.0",
+ "vite": "^2.9.9"
+ }
+}
diff --git a/packages/react/src/App.css b/packages/react/src/App.css
new file mode 100644
index 00000000..8da3fde6
--- /dev/null
+++ b/packages/react/src/App.css
@@ -0,0 +1,42 @@
+.App {
+ text-align: center;
+}
+
+.App-logo {
+ height: 40vmin;
+ pointer-events: none;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .App-logo {
+ animation: App-logo-spin infinite 20s linear;
+ }
+}
+
+.App-header {
+ background-color: #282c34;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ font-size: calc(10px + 2vmin);
+ color: white;
+}
+
+.App-link {
+ color: #61dafb;
+}
+
+@keyframes App-logo-spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+button {
+ font-size: calc(10px + 2vmin);
+}
diff --git a/packages/react/src/App.jsx b/packages/react/src/App.jsx
new file mode 100644
index 00000000..1624d9f7
--- /dev/null
+++ b/packages/react/src/App.jsx
@@ -0,0 +1,47 @@
+import { useState } from 'react'
+import logo from './logo.svg'
+import './App.css'
+import MiniRepl from './components/MiniRepl'
+
+function App() {
+ const [count, setCount] = useState(0)
+
+ return (
+
+
+
+
+ Hello Vite + React!
+
+
+
+
+ Edit App.jsx and save to test HMR updates.
+
+
+
+ Learn React
+
+ {' | '}
+
+ Vite Docs
+
+
+
+
+ )
+}
+
+export default App
diff --git a/packages/react/src/components/CodeMirror6.jsx b/packages/react/src/components/CodeMirror6.jsx
new file mode 100644
index 00000000..ee0cc940
--- /dev/null
+++ b/packages/react/src/components/CodeMirror6.jsx
@@ -0,0 +1,169 @@
+import React from 'react';
+import { CodeMirror as _CodeMirror } from 'react-codemirror6';
+// import { CodeMirrorLite as _CodeMirror } from 'react-codemirror6/dist/lite';
+import { EditorView, Decoration } from '@codemirror/view';
+import { StateField, StateEffect } from '@codemirror/state';
+import { javascript } from '@codemirror/lang-javascript';
+// import { materialPalenight } from 'codemirror6-themes';
+import { materialPalenight } from '../themes/material-palenight';
+
+export const setHighlights = StateEffect.define();
+const highlightField = StateField.define({
+ create() {
+ return Decoration.none;
+ },
+ update(highlights, tr) {
+ try {
+ for (let e of tr.effects) {
+ if (e.is(setHighlights)) {
+ highlights = Decoration.set(
+ e.value
+ .flatMap((hap) =>
+ (hap.context.locations || []).map(({ start, end }) => {
+ const color = hap.context.color || '#FFCA28';
+ let from = tr.newDoc.line(start.line).from + start.column;
+ let to = tr.newDoc.line(end.line).from + end.column;
+ const l = tr.newDoc.length;
+ if (from > l || to > l) {
+ return; // dont mark outside of range, as it will throw an error
+ }
+ const mark = Decoration.mark({ attributes: { style: `outline: 1px solid ${color}` } });
+ return mark.range(from, to);
+ }),
+ )
+ .filter(Boolean),
+ true,
+ );
+ }
+ }
+ return highlights;
+ } catch (err) {
+ // console.warn('highlighting error', err);
+ return highlights;
+ }
+ },
+ provide: (f) => EditorView.decorations.from(f),
+});
+
+export default function CodeMirror({ value, onChange, onViewChanged, onCursor, options, editorDidMount }) {
+ return (
+ <>
+ <_CodeMirror
+ onViewChange={onViewChanged}
+ style={{
+ display: 'flex',
+ flexDirection: 'column',
+ flex: '1 0 auto',
+ }}
+ value={value}
+ onChange={onChange}
+ extensions={[
+ javascript(),
+ materialPalenight,
+ highlightField,
+ // theme, language, ...
+ ]}
+ />
+ >
+ );
+}
+
+let parenMark;
+export const markParens = (editor, data) => {
+ const v = editor.getDoc().getValue();
+ const marked = getCurrentParenArea(v, data);
+ parenMark?.clear();
+ parenMark = editor.getDoc().markText(...marked, { css: 'background-color: #00007720' }); //
+};
+
+// returns { line, ch } from absolute character offset
+export function offsetToPosition(offset, code) {
+ const lines = code.split('\n');
+ let line = 0;
+ let ch = 0;
+ for (let i = 0; i < offset; i++) {
+ if (ch === lines[line].length) {
+ line++;
+ ch = 0;
+ } else {
+ ch++;
+ }
+ }
+ return { line, ch };
+}
+
+// returns absolute character offset from { line, ch }
+export function positionToOffset(position, code) {
+ const lines = code.split('\n');
+ if (position.line > lines.length) {
+ // throw new Error('positionToOffset: position.line > lines.length');
+ return 0;
+ }
+ let offset = 0;
+ for (let i = 0; i < position.line; i++) {
+ offset += lines[i].length + 1;
+ }
+ offset += position.ch;
+ return offset;
+}
+
+// given code and caret position, the functions returns the indices of the parens we are in
+export function getCurrentParenArea(code, caretPosition) {
+ const caret = positionToOffset(caretPosition, code);
+ let open, i, begin, end;
+ // walk left
+ i = caret;
+ open = 0;
+ while (i > 0) {
+ if (code[i - 1] === '(') {
+ open--;
+ } else if (code[i - 1] === ')') {
+ open++;
+ }
+ if (open === -1) {
+ break;
+ }
+ i--;
+ }
+ begin = i;
+ // walk right
+ i = caret;
+ open = 0;
+ while (i < code.length) {
+ if (code[i] === '(') {
+ open--;
+ } else if (code[i] === ')') {
+ open++;
+ }
+ if (open === 1) {
+ break;
+ }
+ i++;
+ }
+ end = i;
+ return [begin, end].map((o) => offsetToPosition(o, code));
+}
+
+/*
+export const markEvent = (editor) => (time, event) => {
+ const locs = event.context.locations;
+ if (!locs || !editor) {
+ return;
+ }
+ const col = event.context?.color || '#FFCA28';
+ // mark active event
+ const marks = locs.map(({ start, end }) =>
+ editor.getDoc().markText(
+ { line: start.line - 1, ch: start.column },
+ { line: end.line - 1, ch: end.column },
+ //{ css: 'background-color: #FFCA28; color: black' } // background-color is now used by parent marking
+ { css: 'outline: 1px solid ' + col + '; box-sizing:border-box' },
+ //{ css: `background-color: ${col};border-radius:5px` },
+ ),
+ );
+ //Tone.Transport.schedule(() => { // problem: this can be cleared by scheduler...
+ setTimeout(() => {
+ marks.forEach((mark) => mark.clear());
+ // }, '+' + event.duration * 0.5);
+ }, event.duration * 1000);
+}; */
diff --git a/packages/react/src/components/MiniRepl.jsx b/packages/react/src/components/MiniRepl.jsx
new file mode 100644
index 00000000..614cf293
--- /dev/null
+++ b/packages/react/src/components/MiniRepl.jsx
@@ -0,0 +1,123 @@
+import React, { useState } from 'react';
+import useRepl from '../hooks/useRepl.mjs';
+import cx from '../cx';
+import useHighlighting from '../hooks/useHighlighting.mjs';
+import { useInView } from 'react-hook-inview';
+
+// eval stuff start
+import { extend } from '@strudel.cycles/eval';
+import * as strudel from '@strudel.cycles/core';
+import gist from '@strudel.cycles/core/gist.js';
+import { mini } from '@strudel.cycles/mini/mini.mjs';
+import { Tone } from '@strudel.cycles/tone';
+import * as toneHelpers from '@strudel.cycles/tone/tone.mjs';
+import * as voicingHelpers from '@strudel.cycles/tonal/voicings.mjs';
+import * as uiHelpers from '@strudel.cycles/tone/ui.mjs';
+import * as drawHelpers from '@strudel.cycles/tone/draw.mjs';
+import euclid from '@strudel.cycles/core/euclid.mjs';
+import '@strudel.cycles/tone/tone.mjs';
+import '@strudel.cycles/midi/midi.mjs';
+import '@strudel.cycles/tonal/voicings.mjs';
+import '@strudel.cycles/tonal/tonal.mjs';
+import '@strudel.cycles/xen/xen.mjs';
+import '@strudel.cycles/xen/tune.mjs';
+import '@strudel.cycles/core/euclid.mjs';
+import '@strudel.cycles/tone/pianoroll.mjs';
+import '@strudel.cycles/tone/draw.mjs';
+import CodeMirror6 from './CodeMirror6';
+
+extend(Tone, strudel, strudel.Pattern.prototype.bootstrap(), toneHelpers, voicingHelpers, drawHelpers, uiHelpers, {
+ gist,
+ euclid,
+ mini,
+ Tone,
+});
+// eval stuff end
+
+const defaultSynth = new Tone.PolySynth().chain(new Tone.Gain(0.5), Tone.Destination).set({
+ oscillator: { type: 'triangle' },
+ envelope: {
+ release: 0.01,
+ },
+});
+
+// "balanced" | "interactive" | "playback";
+// Tone.setContext(new Tone.Context({ latencyHint: 'playback', lookAhead: 1 }));
+function MiniRepl({ tune, maxHeight = 500 }){
+ const { code, setCode, pattern, activateCode, error, cycle, dirty, togglePlay } = useRepl({
+ tune,
+ defaultSynth,
+ autolink: false,
+ });
+ const lines = code.split('\n').length;
+ const [view, setView] = useState();
+ const [ref, isVisible] = useInView({
+ threshold: 0.01,
+ });
+ useHighlighting({ view, pattern, active: cycle.started });
+ return (
+
+
+
+
+
+
+
{error && {error.message}}
{' '}
+
+
+ {isVisible && }
+
+ {/*
*/}
+
+ );
+}
+
+export default MiniRepl;
diff --git a/packages/react/src/cx.js b/packages/react/src/cx.js
new file mode 100644
index 00000000..f806fcf3
--- /dev/null
+++ b/packages/react/src/cx.js
@@ -0,0 +1,9 @@
+/*
+cx.js -
+Copyright (C) 2022 Strudel contributors - see
+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 .
+*/
+
+export default function cx(...classes) { // : Array
+ return classes.filter(Boolean).join(' ');
+}
diff --git a/packages/react/src/favicon.svg b/packages/react/src/favicon.svg
new file mode 100644
index 00000000..de4aeddc
--- /dev/null
+++ b/packages/react/src/favicon.svg
@@ -0,0 +1,15 @@
+
diff --git a/packages/react/src/hooks/useCycle.mjs b/packages/react/src/hooks/useCycle.mjs
new file mode 100644
index 00000000..5a68c9da
--- /dev/null
+++ b/packages/react/src/hooks/useCycle.mjs
@@ -0,0 +1,86 @@
+/*
+useCycle.mjs -
+Copyright (C) 2022 Strudel contributors - see
+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 .
+*/
+
+import { useEffect, useState } from 'react';
+import { Tone } from '@strudel.cycles/tone';
+import { State, TimeSpan } from '@strudel.cycles/core';
+
+/* export declare interface UseCycleProps {
+ onEvent: ToneEventCallback;
+ onQuery?: (state: State) => Hap[];
+ onSchedule?: (events: Hap[], cycle: number) => void;
+ onDraw?: ToneEventCallback;
+ ready?: boolean; // if false, query will not be called on change props
+} */
+
+// function useCycle(props: UseCycleProps) {
+function useCycle(props) {
+ // onX must use useCallback!
+ const { onEvent, onQuery, onSchedule, ready = true, onDraw } = props;
+ const [started, setStarted] = useState(false);
+ const cycleDuration = 1;
+ const activeCycle = () => Math.floor(Tone.getTransport().seconds / cycleDuration);
+
+ // pull events with onQuery + count up to next cycle
+ const query = (cycle = activeCycle()) => {
+ const timespan = new TimeSpan(cycle, cycle + 1);
+ const events = onQuery?.(new State(timespan)) || [];
+ onSchedule?.(events, cycle);
+ // cancel events after current query. makes sure no old events are player for rescheduled cycles
+ // console.log('schedule', cycle);
+ // query next cycle in the middle of the current
+ const cancelFrom = timespan.begin.valueOf();
+ Tone.getTransport().cancel(cancelFrom);
+ // const queryNextTime = (cycle + 1) * cycleDuration - 0.1;
+ const queryNextTime = (cycle + 1) * cycleDuration - 0.5;
+
+ // if queryNextTime would be before current time, execute directly (+0.1 for safety that it won't miss)
+ const t = Math.max(Tone.getTransport().seconds, queryNextTime) + 0.1;
+ Tone.getTransport().schedule(() => {
+ query(cycle + 1);
+ }, t);
+
+ // schedule events for next cycle
+ events
+ ?.filter((event) => event.part.begin.equals(event.whole?.begin))
+ .forEach((event) => {
+ Tone.getTransport().schedule((time) => {
+ onEvent(time, event, Tone.getContext().currentTime);
+ Tone.Draw.schedule(() => {
+ // do drawing or DOM manipulation here
+ onDraw?.(time, event);
+ }, time);
+ }, event.part.begin.valueOf());
+ });
+ };
+
+ useEffect(() => {
+ ready && query();
+ }, [onEvent, onSchedule, onQuery, onDraw, ready]);
+
+ const start = async () => {
+ setStarted(true);
+ await Tone.start();
+ Tone.getTransport().start('+0.1');
+ };
+ const stop = () => {
+ Tone.getTransport().pause();
+ setStarted(false);
+ };
+ const toggle = () => (started ? stop() : start());
+ return {
+ start,
+ stop,
+ onEvent,
+ started,
+ setStarted,
+ toggle,
+ query,
+ activeCycle,
+ };
+}
+
+export default useCycle;
diff --git a/packages/react/src/hooks/useHighlighting.mjs b/packages/react/src/hooks/useHighlighting.mjs
new file mode 100644
index 00000000..420c6519
--- /dev/null
+++ b/packages/react/src/hooks/useHighlighting.mjs
@@ -0,0 +1,41 @@
+import { useEffect } from 'react';
+import { setHighlights } from '../components/CodeMirror6';
+import { Tone } from '@strudel.cycles/tone';
+
+let highlights = []; // actively highlighted events
+let lastEnd;
+
+function useHighlighting({ view, pattern, active }) {
+ useEffect(() => {
+ if (view) {
+ if (pattern && active) {
+ let frame = requestAnimationFrame(updateHighlights);
+
+ function updateHighlights() {
+ try {
+ const audioTime = Tone.getTransport().seconds;
+ const span = [lastEnd || audioTime, audioTime + 1 / 60];
+ lastEnd = audioTime + 1 / 60;
+ highlights = highlights.filter((hap) => hap.whole.end > audioTime); // keep only highlights that are still active
+ const haps = pattern.queryArc(...span).filter((hap) => hap.hasOnset());
+ highlights = highlights.concat(haps); // add potential new onsets
+ view.dispatch({ effects: setHighlights.of(highlights) }); // highlight all still active + new active haps
+ } catch (err) {
+ // console.log('error in updateHighlights', err);
+ view.dispatch({ effects: setHighlights.of([]) });
+ }
+ frame = requestAnimationFrame(updateHighlights);
+ }
+
+ return () => {
+ cancelAnimationFrame(frame);
+ };
+ } else {
+ highlights = [];
+ view.dispatch({ effects: setHighlights.of([]) });
+ }
+ }
+ }, [pattern, active, view]);
+}
+
+export default useHighlighting;
diff --git a/packages/react/src/hooks/usePostMessage.mjs b/packages/react/src/hooks/usePostMessage.mjs
new file mode 100644
index 00000000..9d3bc8e7
--- /dev/null
+++ b/packages/react/src/hooks/usePostMessage.mjs
@@ -0,0 +1,17 @@
+/*
+usePostMessage.mjs -
+Copyright (C) 2022 Strudel contributors - see
+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 .
+*/
+
+import { useEffect, useCallback } from 'react';
+
+function usePostMessage(listener) {
+ useEffect(() => {
+ window.addEventListener('message', listener);
+ return () => window.removeEventListener('message', listener);
+ }, [listener]);
+ return useCallback((data) => window.postMessage(data, '*'), []);
+}
+
+export default usePostMessage;
diff --git a/packages/react/src/hooks/useRepl.mjs b/packages/react/src/hooks/useRepl.mjs
new file mode 100644
index 00000000..4f3791d6
--- /dev/null
+++ b/packages/react/src/hooks/useRepl.mjs
@@ -0,0 +1,157 @@
+/*
+useRepl.mjs -
+Copyright (C) 2022 Strudel contributors - see
+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 .
+*/
+
+import { useCallback, useState, useMemo } from 'react';
+import { evaluate } from '@strudel.cycles/eval';
+import { getPlayableNoteValue } from '@strudel.cycles/core/util.mjs';
+import useCycle from './useCycle.mjs';
+import usePostMessage from './usePostMessage.mjs';
+
+let s4 = () => {
+ return Math.floor((1 + Math.random()) * 0x10000)
+ .toString(16)
+ .substring(1);
+};
+const generateHash = (code) => encodeURIComponent(btoa(code));
+
+function useRepl({ tune, defaultSynth, autolink = true, onEvent, onDraw: onDrawProp }) {
+ const id = useMemo(() => s4(), []);
+ const [code, setCode] = useState(tune);
+ const [activeCode, setActiveCode] = useState();
+ const [log, setLog] = useState('');
+ const [error, setError] = useState();
+ const [pending, setPending] = useState(false);
+ const [hash, setHash] = useState('');
+ const [pattern, setPattern] = useState();
+ const dirty = useMemo(() => code !== activeCode || error, [code, activeCode, error]);
+ const pushLog = useCallback((message) => setLog((log) => log + `${log ? '\n\n' : ''}${message}`), []);
+
+ // below block allows disabling the highlighting by including "strudel disable-highlighting" in the code (as comment)
+ const onDraw = useMemo(() => {
+ if (activeCode && !activeCode.includes('strudel disable-highlighting')) {
+ return (time, event) => onDrawProp?.(time, event, activeCode);
+ }
+ }, [activeCode, onDrawProp]);
+
+ // cycle hook to control scheduling
+ const cycle = useCycle({
+ onDraw,
+ onEvent: useCallback(
+ (time, event, currentTime) => {
+ try {
+ onEvent?.(event);
+ if (event.context.logs?.length) {
+ event.context.logs.forEach(pushLog);
+ }
+ const { onTrigger, velocity } = event.context;
+ if (!onTrigger) {
+ if (defaultSynth) {
+ const note = getPlayableNoteValue(event);
+ defaultSynth.triggerAttackRelease(note, event.duration.valueOf(), time, velocity);
+ } else {
+ throw new Error('no defaultSynth passed to useRepl.');
+ }
+ /* console.warn('no instrument chosen', event);
+ throw new Error(`no instrument chosen for ${JSON.stringify(event)}`); */
+ } else {
+ onTrigger(time, event, currentTime);
+ }
+ } catch (err) {
+ console.warn(err);
+ err.message = 'unplayable event: ' + err?.message;
+ pushLog(err.message); // not with setError, because then we would have to setError(undefined) on next playable event
+ }
+ },
+ [onEvent, pushLog, defaultSynth],
+ ),
+ onQuery: useCallback(
+ (state) => {
+ try {
+ return pattern?.query(state) || [];
+ } catch (err) {
+ console.warn(err);
+ err.message = 'query error: ' + err.message;
+ setError(err);
+ return [];
+ }
+ },
+ [pattern],
+ ),
+ onSchedule: useCallback((_events, cycle) => logCycle(_events, cycle), []),
+ ready: !!pattern && !!activeCode,
+ });
+
+ const broadcast = usePostMessage(({ data: { from, type } }) => {
+ if (type === 'start' && from !== id) {
+ // console.log('message', from, type);
+ cycle.setStarted(false);
+ setActiveCode(undefined);
+ }
+ });
+
+ const activateCode = useCallback(
+ async (_code = code) => {
+ if (activeCode && !dirty) {
+ setError(undefined);
+ cycle.start();
+ return;
+ }
+ try {
+ setPending(true);
+ const parsed = await evaluate(_code);
+ cycle.start();
+ broadcast({ type: 'start', from: id });
+ setPattern(() => parsed.pattern);
+ if (autolink) {
+ window.location.hash = '#' + encodeURIComponent(btoa(code));
+ }
+ setHash(generateHash(code));
+ setError(undefined);
+ setActiveCode(_code);
+ setPending(false);
+ } catch (err) {
+ err.message = 'evaluation error: ' + err.message;
+ console.warn(err);
+ setError(err);
+ }
+ },
+ [activeCode, dirty, code, cycle, autolink, id, broadcast],
+ );
+ // logs events of cycle
+ const logCycle = (_events, cycle) => {
+ if (_events.length) {
+ // pushLog(`# cycle ${cycle}\n` + _events.map((e: any) => e.show()).join('\n'));
+ }
+ };
+
+ const togglePlay = () => {
+ if (!cycle.started) {
+ activateCode();
+ } else {
+ cycle.stop();
+ }
+ };
+
+ return {
+ pending,
+ code,
+ setCode,
+ pattern,
+ error,
+ cycle,
+ setPattern,
+ dirty,
+ log,
+ togglePlay,
+ setActiveCode,
+ activateCode,
+ activeCode,
+ pushLog,
+ hash,
+ };
+}
+
+export default useRepl;
diff --git a/packages/react/src/hooks/useWebMidi.mjs b/packages/react/src/hooks/useWebMidi.mjs
new file mode 100644
index 00000000..ee3f0db3
--- /dev/null
+++ b/packages/react/src/hooks/useWebMidi.mjs
@@ -0,0 +1,41 @@
+/*
+useWebMidi.js -
+Copyright (C) 2022 Strudel contributors - see
+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 .
+*/
+
+import { useEffect, useState } from 'react';
+import { enableWebMidi, WebMidi } from '@strudel.cycles/midi'
+
+export function useWebMidi(props) {
+ const { ready, connected, disconnected } = props;
+ const [loading, setLoading] = useState(true);
+ const [outputs, setOutputs] = useState(WebMidi?.outputs || []);
+ useEffect(() => {
+ enableWebMidi()
+ .then(() => {
+ // Reacting when a new device becomes available
+ WebMidi.addListener('connected', (e) => {
+ setOutputs([...WebMidi.outputs]);
+ connected?.(WebMidi, e);
+ });
+ // Reacting when a device becomes unavailable
+ WebMidi.addListener('disconnected', (e) => {
+ setOutputs([...WebMidi.outputs]);
+ disconnected?.(WebMidi, e);
+ });
+ ready?.(WebMidi);
+ setLoading(false);
+ })
+ .catch((err) => {
+ if (err) {
+ console.error(err);
+ //throw new Error("Web Midi could not be enabled...");
+ console.warn('Web Midi could not be enabled..');
+ return;
+ }
+ });
+ }, [ready, connected, disconnected, outputs]);
+ const outputByName = (name) => WebMidi.getOutputByName(name);
+ return { loading, outputs, outputByName };
+}
diff --git a/packages/react/src/index.css b/packages/react/src/index.css
new file mode 100644
index 00000000..ec2585e8
--- /dev/null
+++ b/packages/react/src/index.css
@@ -0,0 +1,13 @@
+body {
+ margin: 0;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
+ 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
+ sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+code {
+ font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
+ monospace;
+}
diff --git a/packages/react/src/logo.svg b/packages/react/src/logo.svg
new file mode 100644
index 00000000..6b60c104
--- /dev/null
+++ b/packages/react/src/logo.svg
@@ -0,0 +1,7 @@
+
diff --git a/packages/react/src/main.jsx b/packages/react/src/main.jsx
new file mode 100644
index 00000000..b2e5c0ac
--- /dev/null
+++ b/packages/react/src/main.jsx
@@ -0,0 +1,6 @@
+import React from 'react'
+import ReactDOM from 'react-dom'
+import App from './App'
+import './index.css'
+
+ReactDOM.render(,document.getElementById('root'))
\ No newline at end of file
diff --git a/packages/react/src/themes/material-palenight.js b/packages/react/src/themes/material-palenight.js
new file mode 100644
index 00000000..5ee559c9
--- /dev/null
+++ b/packages/react/src/themes/material-palenight.js
@@ -0,0 +1,135 @@
+import { EditorView } from '@codemirror/view';
+import { HighlightStyle, tags as t } from '@codemirror/highlight';
+
+/*
+ Credits for color palette:
+
+ Author: Mattia Astorino (http://github.com/equinusocio)
+ Website: https://material-theme.site/
+*/
+
+const ivory = '#abb2bf',
+ stone = '#7d8799', // Brightened compared to original to increase contrast
+ invalid = '#ffffff',
+ darkBackground = '#21252b',
+ highlightBackground = 'rgba(0, 0, 0, 0.5)',
+ // background = '#292d3e',
+ background = 'transparent',
+ tooltipBackground = '#353a42',
+ selection = 'rgba(128, 203, 196, 0.2)',
+ cursor = '#ffcc00';
+
+/// The editor theme styles for Material Palenight.
+export const materialPalenightTheme = EditorView.theme(
+ {
+ // done
+ '&': {
+ color: '#ffffff',
+ backgroundColor: background,
+ fontSize: '15px',
+ 'z-index': 11,
+ },
+
+ // done
+ '.cm-content': {
+ caretColor: cursor,
+ lineHeight: '22px',
+ },
+ '.cm-line': {
+ background: '#2C323699',
+ },
+ // done
+ '&.cm-focused .cm-cursor': {
+ borderLeftColor: cursor,
+ },
+
+ '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
+ backgroundColor: selection,
+ },
+
+ '.cm-panels': { backgroundColor: darkBackground, color: '#ffffff' },
+ '.cm-panels.cm-panels-top': { borderBottom: '2px solid black' },
+ '.cm-panels.cm-panels-bottom': { borderTop: '2px solid black' },
+
+ // done, use onedarktheme
+ '.cm-searchMatch': {
+ backgroundColor: '#72a1ff59',
+ outline: '1px solid #457dff',
+ },
+ '.cm-searchMatch.cm-searchMatch-selected': {
+ backgroundColor: '#6199ff2f',
+ },
+
+ '.cm-activeLine': { backgroundColor: highlightBackground },
+ '.cm-selectionMatch': { backgroundColor: '#aafe661a' },
+
+ '&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket': {
+ backgroundColor: '#bad0f847',
+ outline: '1px solid #515a6b',
+ },
+
+ // done
+ '.cm-gutters': {
+ background: '#2C323699',
+ color: '#676e95',
+ 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 Material Palenight theme.
+export const materialPalenightHighlightStyle = HighlightStyle.define([
+ { tag: t.keyword, color: '#c792ea' },
+ { tag: t.operator, color: '#89ddff' },
+ { tag: t.special(t.variableName), color: '#eeffff' },
+ { tag: t.typeName, color: '#f07178' },
+ { tag: t.atom, color: '#f78c6c' },
+ { tag: t.number, color: '#ff5370' },
+ { tag: t.definition(t.variableName), color: '#82aaff' },
+ { tag: t.string, color: '#c3e88d' },
+ { tag: t.special(t.string), color: '#f07178' },
+ { tag: t.comment, color: stone },
+ { tag: t.variableName, color: '#f07178' },
+ { tag: t.tagName, color: '#ff5370' },
+ { tag: t.bracket, color: '#a2a1a4' },
+ { tag: t.meta, color: '#ffcb6b' },
+ { tag: t.attributeName, color: '#c792ea' },
+ { tag: t.propertyName, color: '#c792ea' },
+ { tag: t.className, color: '#decb6b' },
+ { tag: t.invalid, color: invalid },
+]);
+
+/// Extension to enable the Material Palenight theme (both the editor theme and
+/// the highlight style).
+// : Extension
+export const materialPalenight = [materialPalenightTheme, materialPalenightHighlightStyle];
diff --git a/packages/react/vite.config.js b/packages/react/vite.config.js
new file mode 100644
index 00000000..b1b5f91e
--- /dev/null
+++ b/packages/react/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()]
+})