diff --git a/repl/README.md b/repl/README.md index 67339400..0df11d94 100644 --- a/repl/README.md +++ b/repl/README.md @@ -47,3 +47,4 @@ currently broken / buggy: - [ ] find a way to display errors when console is closed / another tab selected - [x] scheduler.getPhase is quantized to clock interval - => draw was choppy + that also caused useHighlighting bugs +- [ ] pianoroll keeps rolling when pressing stop \ No newline at end of file diff --git a/repl/src/App.jsx b/repl/src/App.jsx index f84eec76..6fc03602 100644 --- a/repl/src/App.jsx +++ b/repl/src/App.jsx @@ -4,25 +4,24 @@ Copyright (C) 2022 Strudel contributors - see . */ -import { CodeMirror, cx, flash, useHighlighting } from '@strudel.cycles/react'; -import React, { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react'; -import './App.css'; -import * as tunes from './tunes.mjs'; -import { prebake } from './prebake.mjs'; -import * as WebDirt from 'WebDirt'; -import { resetLoadedSamples, getAudioContext, getLoadedSamples } from '@strudel.cycles/webaudio'; -import { controls, evalScope, logger, cleanupDraw, cleanupUi } from '@strudel.cycles/core'; +import { cleanupDraw, cleanupUi, controls, evalScope, logger } from '@strudel.cycles/core'; +import { CodeMirror, cx, flash, useHighlighting, useStrudel } from '@strudel.cycles/react'; +import { + getAudioContext, + getLoadedSamples, + initAudioOnFirstClick, + resetLoadedSamples, + webaudioOutput, +} from '@strudel.cycles/webaudio'; import { createClient } from '@supabase/supabase-js'; import { nanoid } from 'nanoid'; -import { useStrudel } from '@strudel.cycles/react'; -import { webaudioOutput, initAudioOnFirstClick } from '@strudel.cycles/webaudio'; -import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon'; -import StopCircleIcon from '@heroicons/react/20/solid/StopCircleIcon'; -import CommandLineIcon from '@heroicons/react/20/solid/CommandLineIcon'; -import SparklesIcon from '@heroicons/react/20/solid/SparklesIcon'; -import LinkIcon from '@heroicons/react/20/solid/LinkIcon'; -import XMarkIcon from '@heroicons/react/20/solid/XMarkIcon'; -import AcademicCapIcon from '@heroicons/react/20/solid/AcademicCapIcon'; +import React, { createContext, useCallback, useEffect, useState } from 'react'; +import * as WebDirt from 'WebDirt'; +import './App.css'; +import { Footer } from './Footer'; +import { Header } from './Header'; +import { prebake } from './prebake.mjs'; +import * as tunes from './tunes.mjs'; initAudioOnFirstClick(); @@ -52,7 +51,7 @@ evalScope( ...modules, ); -let loadedSamples = []; +export let loadedSamples = []; const presets = prebake(); Promise.all([...modules, presets]).then((data) => { @@ -102,66 +101,25 @@ function getRandomTune() { } const { code: randomTune, name } = getRandomTune(); -const isEmbedded = window.location !== window.parent.location; + +export const AppContext = createContext(); + function App() { const [view, setView] = useState(); // codemirror view const [lastShared, setLastShared] = useState(); - const [activeFooter, setActiveFooter] = useState('console'); - // logger - const [log, setLog] = useState([]); - useLogger( - useCallback((e) => { - const { message, type, data } = e.detail; - setLog((l) => { - const lastLog = l.length ? l[l.length - 1] : undefined; - const id = nanoid(12); - // if (type === 'loaded-sample' && lastLog.type === 'load-sample' && lastLog.url === data.url) { - if (type === 'loaded-sample') { - // const loadIndex = l.length - 1; - const loadIndex = l.findIndex(({ data: { url }, type }) => type === 'load-sample' && url === data.url); - l[loadIndex] = { message, type, id, data }; - } else if (lastLog && lastLog.message === message) { - l = l.slice(0, -1).concat([{ message, type, count: (lastLog.count ?? 1) + 1, id, data }]); - } else { - l = l.concat([{ message, type, id, data }]); - } - return l.slice(-20); - }); - }, []), - ); - const footerContent = useRef(); - useLayoutEffect(() => { - if (footerContent.current && activeFooter === 'console') { - // scroll log box to bottom when log changes - footerContent.current.scrollTop = footerContent.current?.scrollHeight; - } - }, [log, activeFooter]); - useLayoutEffect(() => { - if (!footerContent.current) { - } else if (activeFooter === 'console') { - footerContent.current.scrollTop = footerContent.current?.scrollHeight; - } else { - footerContent.current.scrollTop = 0; - } - }, [activeFooter]); - - const { code, setCode, scheduler, evaluate, activateCode, error, isDirty, activeCode, pattern, started, stop } = - useStrudel({ - initialCode: '// LOADING', - defaultOutput: webaudioOutput, - getTime, - autolink: true, - onEvalError: () => { - setActiveFooter('console'); - }, - }); + const { code, setCode, scheduler, evaluate, activateCode, isDirty, activeCode, pattern, started, stop } = useStrudel({ + initialCode: '// LOADING', + defaultOutput: webaudioOutput, + getTime, + autolink: true, + }); // init code useEffect(() => { initCode().then((decoded) => { if (!decoded) { - setActiveFooter('intro'); + setActiveFooter('intro'); // TODO: get rid } logger( `Welcome to Strudel! ${ @@ -173,6 +131,7 @@ function App() { }); }, []); + // keyboard shortcuts useKeydown( useCallback( async (e) => { @@ -191,17 +150,22 @@ function App() { ), ); + // highlighting useHighlighting({ view, pattern, active: started && !activeCode?.includes('strudel disable-highlighting'), - getTime: () => scheduler.getPhase(), // TODO: problem: phase is quantized to clock interval... + getTime: () => scheduler.getPhase(), }); // // UI Actions // + const handleChangeCode = (c) => { + setCode(c); + started && logger('[edit] code changed. hit ctrl+enter to update'); + }; const handleTogglePlay = async () => { await getAudioContext().resume(); // fixes no sound in ios webkit if (!started) { @@ -210,6 +174,7 @@ function App() { } else { logger('[repl] stopped. tip: you can also stop by pressing ctrl+dot', 'highlight'); stop(); + // cleanupDraw(); } }; const handleUpdate = () => { @@ -254,248 +219,42 @@ function App() { } }; - const handleChangeCode = (c) => { - setCode(c); - started && logger('[edit] code changed. hit ctrl+enter to update'); - }; - - const FooterTab = ({ children, name, label }) => ( - <> -
setActiveFooter(name)} - className={cx( - 'h-8 px-2 text-white cursor-pointer hover:text-tertiary flex items-center space-x-1 border-b', - activeFooter === name ? 'border-white hover:border-tertiary' : 'border-transparent', - )} - > - {label || name} -
- {activeFooter === name && <>{children}} - - ); - return ( // bg-gradient-to-t from-blue-900 to-slate-900 // bg-gradient-to-t from-green-900 to-slate-900 -
- {!hideHeader && ( - - )} -
- -
-
-
-
- - - -
- {activeFooter !== '' && ( - - )} -
- {activeFooter !== '' && ( -
- {activeFooter === 'intro' && ( -
-

- 🌀 welcome -

-

- You have found strudel, a new live coding platform to write dynamic - music pieces in the browser! It is free and open-source and made for beginners and experts alike. To - get started: -
-
- 1. hit play -{' '} - 2. change something -{' '} - 3. hit update -
- If you don't like what you hear, try shuffle! -

-

- To learn more about what this all means, check out the{' '} - - interactive tutorial - - . Also feel free to join the{' '} - - tidalcycles discord channel - {' '} - to ask any questions, give feedback or just say hello. -

-

about

-

- strudel is a JavaScript version of{' '} - - tidalcycles - - , which is a popular live coding language for music, written in Haskell. You can find the source code - at{' '} - - github - - . Please consider to{' '} - - support this project - {' '} - to ensure ongoing development 💖 -

-
- )} - {activeFooter === 'console' && ( -
- {log.map((l, i) => { - const message = linkify(l.message); - return ( -
- - {l.count ? ` (${l.count})` : ''} -
- ); - })} -
- )} - {activeFooter === 'samples' && ( -
- {loadedSamples.length} banks loaded: - {loadedSamples.map(([name, samples]) => ( - {}}> - {' '} - {name}( - {Array.isArray(samples) - ? samples.length - : typeof samples === 'object' - ? Object.values(samples).length - : 1} - ){' '} - - ))} -
- )} -
+
-
+ > + {!hideHeader &&
} +
+ +
+
+
+ ); } export default App; -function useEvent(name, onTrigger, useCapture = false) { +export function useEvent(name, onTrigger, useCapture = false) { useEffect(() => { document.addEventListener(name, onTrigger, useCapture); return () => { @@ -503,32 +262,6 @@ function useEvent(name, onTrigger, useCapture = false) { }; }, [onTrigger]); } - -function useLogger(onTrigger) { - useEvent(logger.key, onTrigger); -} - function useKeydown(onTrigger) { useEvent('keydown', onTrigger, true); } - -function linkify(inputText) { - var replacedText, replacePattern1, replacePattern2, replacePattern3; - - //URLs starting with http://, https://, or ftp:// - replacePattern1 = /(\b(https?|ftp):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gim; - replacedText = inputText.replace(replacePattern1, '$1'); - - //URLs starting with "www." (without // before it, or it'd re-link the ones done above). - replacePattern2 = /(^|[^\/])(www\.[\S]+(\b|$))/gim; - replacedText = replacedText.replace( - replacePattern2, - '$1$2', - ); - - //Change email addresses to mailto:: links. - replacePattern3 = /(([a-zA-Z0-9\-\_\.])+@[a-zA-Z\_]+?(\.[a-zA-Z]{2,6})+)/gim; - replacedText = replacedText.replace(replacePattern3, '$1'); - - return replacedText; -} diff --git a/repl/src/Footer.jsx b/repl/src/Footer.jsx new file mode 100644 index 00000000..39a26697 --- /dev/null +++ b/repl/src/Footer.jsx @@ -0,0 +1,192 @@ +import XMarkIcon from '@heroicons/react/20/solid/XMarkIcon'; +import { logger } from '@strudel.cycles/core'; +import { cx } from '@strudel.cycles/react'; +import { nanoid } from 'nanoid'; +import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { useEvent, loadedSamples } from './App'; + +export function Footer() { + const [activeFooter, setActiveFooter] = useState('console'); + const footerContent = useRef(); + const [log, setLog] = useState([]); + + useLayoutEffect(() => { + if (footerContent.current && activeFooter === 'console') { + // scroll log box to bottom when log changes + footerContent.current.scrollTop = footerContent.current?.scrollHeight; + } + }, [log, activeFooter]); + useLayoutEffect(() => { + if (!footerContent.current) { + } else if (activeFooter === 'console') { + footerContent.current.scrollTop = footerContent.current?.scrollHeight; + } else { + footerContent.current.scrollTop = 0; + } + }, [activeFooter]); + + useLogger( + useCallback((e) => { + const { message, type, data } = e.detail; + if (type === 'error') { + setActiveFooter('console'); + } + setLog((l) => { + const lastLog = l.length ? l[l.length - 1] : undefined; + const id = nanoid(12); + // if (type === 'loaded-sample' && lastLog.type === 'load-sample' && lastLog.url === data.url) { + if (type === 'loaded-sample') { + // const loadIndex = l.length - 1; + const loadIndex = l.findIndex(({ data: { url }, type }) => type === 'load-sample' && url === data.url); + l[loadIndex] = { message, type, id, data }; + } else if (lastLog && lastLog.message === message) { + l = l.slice(0, -1).concat([{ message, type, count: (lastLog.count ?? 1) + 1, id, data }]); + } else { + l = l.concat([{ message, type, id, data }]); + } + return l.slice(-20); + }); + }, []), + ); + + const FooterTab = ({ children, name, label }) => ( + <> +
setActiveFooter(name)} + className={cx( + 'h-8 px-2 text-white cursor-pointer hover:text-tertiary flex items-center space-x-1 border-b', + activeFooter === name ? 'border-white hover:border-tertiary' : 'border-transparent', + )} + > + {label || name} +
+ {activeFooter === name && <>{children}} + + ); + return ( + + ); +} + +function useLogger(onTrigger) { + useEvent(logger.key, onTrigger); +} + +function linkify(inputText) { + var replacedText, replacePattern1, replacePattern2, replacePattern3; + + //URLs starting with http://, https://, or ftp:// + replacePattern1 = /(\b(https?|ftp):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gim; + replacedText = inputText.replace(replacePattern1, '$1'); + + //URLs starting with "www." (without // before it, or it'd re-link the ones done above). + replacePattern2 = /(^|[^\/])(www\.[\S]+(\b|$))/gim; + replacedText = replacedText.replace( + replacePattern2, + '$1$2', + ); + + //Change email addresses to mailto:: links. + replacePattern3 = /(([a-zA-Z0-9\-\_\.])+@[a-zA-Z\_]+?(\.[a-zA-Z]{2,6})+)/gim; + replacedText = replacedText.replace(replacePattern3, '$1'); + + return replacedText; +} diff --git a/repl/src/Header.jsx b/repl/src/Header.jsx new file mode 100644 index 00000000..07de9979 --- /dev/null +++ b/repl/src/Header.jsx @@ -0,0 +1,128 @@ +import AcademicCapIcon from '@heroicons/react/20/solid/AcademicCapIcon'; +import CommandLineIcon from '@heroicons/react/20/solid/CommandLineIcon'; +import LinkIcon from '@heroicons/react/20/solid/LinkIcon'; +import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon'; +import SparklesIcon from '@heroicons/react/20/solid/SparklesIcon'; +import StopCircleIcon from '@heroicons/react/20/solid/StopCircleIcon'; +import { cx } from '@strudel.cycles/react'; +import React, { useContext } from 'react'; +import { AppContext } from './App'; +import './App.css'; + +const isEmbedded = window.location !== window.parent.location; + +export function Header() { + const { + started, + pending, + isDirty, + lastShared, + activeCode, + handleTogglePlay, + handleUpdate, + handleShuffle, + handleShare, + } = useContext(AppContext); + return ( + + ); +}