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 ( +
+ +
+ logo +

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 && } +
+ {/*
+ + open in REPL + + + + + +
*/} +
+ ); +} + +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()] +})