1import { useState, useRef, useCallback, useMemo, useEffect } from 'react'; 2import { Icon } from './Icon'; 3import { silence, noteToMidi, _mod } from '@strudel/core'; 4import { getDrawContext, getPunchcardPainter } from '@strudel/draw'; 5import { transpiler } from '@strudel/transpiler'; 6import { getAudioContext, webaudioOutput, initAudioOnFirstClick } from '@strudel/webaudio'; 7import { StrudelMirror } from '@strudel/codemirror'; 8import { prebake } from '../repl/prebake.mjs'; 9import { loadModules, setVersionDefaultsFrom } from '../repl/util.mjs'; 10import Claviature from '@components/Claviature'; 11import useClient from '@src/useClient.mjs'; 12 13let prebaked, modulesLoading, audioReady; 14if (typeof window !== 'undefined') { 15 prebaked = prebake(); 16 modulesLoading = loadModules(); 17 audioReady = initAudioOnFirstClick(); 18} 19 20export function MiniRepl({ 21 tune, 22 tunes, 23 hideHeader = false, 24 canvasHeight = 100, 25 onTrigger, 26 punchcard, 27 punchcardLabels = true, 28 claviature, 29 claviatureLabels, 30 maxHeight, 31 autodraw, 32 drawTime, 33 mondo = false, 34}) { 35 const code = tunes ? tunes[0] : tune; 36 const id = useMemo(() => s4(), []); 37 const shouldShowCanvas = !!punchcard; 38 const canvasId = shouldShowCanvas ? useMemo(() => `canvas-${id}`, [id]) : null; 39 autodraw = !!punchcard || !!claviature || !!autodraw; 40 drawTime = (drawTime ?? punchcard) ? [0, 4] : [-2, 2]; 41 if (claviature) { 42 drawTime = [0, 0]; 43 } 44 const [activeNotes, setActiveNotes] = useState([]); 45 46 const init = useCallback(({ code, autodraw }) => { 47 const drawContext = canvasId ? document.querySelector('#' + canvasId)?.getContext('2d') : getDrawContext(); 48 49 const editor = new StrudelMirror({ 50 id, 51 defaultOutput: webaudioOutput, 52 getTime: () => getAudioContext().currentTime, 53 transpiler, 54 autodraw, 55 root: containerRef.current, 56 initialCode: '// LOADING', 57 pattern: silence, 58 drawTime, 59 drawContext, 60 editPattern: (pat, id) => { 61 if (onTrigger) { 62 pat = pat.onTrigger(onTrigger, false); 63 } 64 if (claviature) { 65 pat = pat.onPaint((ctx, time, haps, drawTime) => { 66 const active = haps 67 .map((hap) => hap.value.note) 68 .filter(Boolean) 69 .map((n) => (typeof n === 'string' ? noteToMidi(n) : n)); 70 setActiveNotes(active); 71 }); 72 } 73 if (punchcard) { 74 pat = pat.punchcard({ labels: !!punchcardLabels }); 75 } 76 return pat; 77 }, 78 prebake: async () => Promise.all([modulesLoading, prebaked]), 79 onUpdateState: (state) => { 80 setReplState({ ...state }); 81 }, 82 onToggle: (playing) => { 83 if (!playing) { 84 // clearHydra(); // TBD: doesn't work with multiple MiniRepl's on a page 85 } 86 }, 87 beforeStart: () => audioReady, 88 afterEval: ({ code }) => setVersionDefaultsFrom(code), 89 mondo, 90 }); 91 // init settings 92 editor.setCode(code); 93 editorRef.current = editor; 94 }, []); 95 96 const [replState, setReplState] = useState({}); 97 const { started, isDirty, error } = replState; 98 const editorRef = useRef(); 99 const containerRef = useRef(); 100 const client = useClient(); 101 102 const [tuneIndex, setTuneIndex] = useState(0); 103 const changeTune = (index) => { 104 index = _mod(index, tunes.length); 105 setTuneIndex(index); 106 editorRef.current?.setCode(tunes[index]); 107 editorRef.current?.evaluate(); 108 }; 109 110 if (!client) { 111 return <pre>{code}</pre>; 112 } 113 114 return ( 115 <div className="overflow-hidden rounded-t-md bg-background border border-lineHighlight"> 116 {!hideHeader && ( 117 <div className="flex justify-between bg-lineHighlight"> 118 <div className="flex"> 119 <button 120 className={cx( 121 'cursor-pointer w-16 flex items-center justify-center p-1 border-r border-lineHighlight text-foreground bg-lineHighlight hover:bg-background', 122 started ? 'animate-pulse' : '', 123 )} 124 aria-label={started ? 'stop' : 'play'} 125 onClick={() => editorRef.current?.toggle()} 126 > 127 <Icon type={started ? 'stop' : 'play'} /> 128 </button> 129 <button 130 className={cx( 131 'w-16 flex items-center justify-center p-1 text-foreground border-lineHighlight bg-lineHighlight', 132 isDirty ? 'text-foreground hover:bg-background cursor-pointer' : 'opacity-50 cursor-not-allowed', 133 )} 134 aria-label="update" 135 onClick={() => editorRef.current?.evaluate()} 136 > 137 <Icon type="refresh" /> 138 </button> 139 </div> 140 {tunes && ( 141 <div className="flex"> 142 <button 143 className={ 144 'cursor-pointer w-16 flex items-center justify-center p-1 border-r border-lineHighlight text-foreground bg-lineHighlight hover:bg-background' 145 } 146 aria-label="previous example" 147 onClick={() => changeTune(tuneIndex - 1)} 148 > 149 <div className="rotate-180"> 150 <Icon type="skip" /> 151 </div> 152 </button> 153 <button 154 className={ 155 'cursor-pointer w-16 flex items-center justify-center p-1 border-r border-lineHighlight text-foreground bg-lineHighlight hover:bg-background' 156 } 157 aria-label="next example" 158 onClick={() => changeTune(tuneIndex + 1)} 159 > 160 <Icon type="skip" /> 161 </button> 162 </div> 163 )} 164 </div> 165 )} 166 <div className="overflow-auto relative p-1" style={maxHeight ? { maxHeight: `${maxHeight}px` } : {}}> 167 <div 168 ref={(el) => { 169 if (!editorRef.current) { 170 containerRef.current = el; 171 init({ code, autodraw }); 172 } 173 }} 174 ></div> 175 {error && <div className="text-right p-1 text-md text-red-200">{error.message}</div>} 176 </div> 177 {shouldShowCanvas && ( 178 <canvas 179 id={canvasId} 180 className="w-full pointer-events-none border-t border-lineHighlight" 181 height={canvasHeight} 182 ref={(el) => { 183 if (el && el.width !== el.clientWidth) { 184 el.width = el.clientWidth; 185 } 186 }} 187 ></canvas> 188 )} 189 {/* !!log.length && ( 190 <div className="bg-gray-800 rounded-md p-2"> 191 {log.map(({ message }, i) => ( 192 <div key={i}>{message}</div> 193 ))} 194 </div> 195 ) */} 196 {claviature && ( 197 <Claviature 198 options={{ 199 range: ['C2', 'C6'], 200 scaleY: 0.75, 201 colorize: [{ keys: activeNotes, color: 'steelblue' }], 202 labels: claviatureLabels || {}, 203 }} 204 /> 205 )} 206 </div> 207 ); 208} 209 210function cx(...classes) { 211 // : Array<string | undefined> 212 return classes.filter(Boolean).join(' '); 213} 214 215function s4() { 216 return Math.floor((1 + Math.random()) * 0x10000) 217 .toString(16) 218 .substring(1); 219}