jevstrudel.git / website / src / docs / MiniRepl.jsx
MiniRepl.jsx219 lines · 7.0 KB · raw
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}