Merge pull request #769 from tidalcycles/patterns-tab
Patterns tab + Refactor Panel
This commit is contained in:
commit
f8d1e9e004
16 changed files with 772 additions and 509 deletions
|
|
@ -74,8 +74,11 @@ function useStrudel({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const activateCode = useCallback(
|
const activateCode = useCallback(
|
||||||
async (autostart = true) => {
|
async (newCode, autostart = true) => {
|
||||||
const res = await evaluate(code, autostart);
|
if (newCode) {
|
||||||
|
setCode(code);
|
||||||
|
}
|
||||||
|
const res = await evaluate(newCode || code, autostart);
|
||||||
broadcast({ type: 'start', from: id });
|
broadcast({ type: 'start', from: id });
|
||||||
return res;
|
return res;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { noteToMidi, valueToMidi } from './util.mjs';
|
import { noteToMidi, valueToMidi, nanFallback } from './util.mjs';
|
||||||
import { getAudioContext, registerSound } from './index.mjs';
|
import { getAudioContext, registerSound } from './index.mjs';
|
||||||
import { getEnvelope } from './helpers.mjs';
|
import { getEnvelope } from './helpers.mjs';
|
||||||
import { logger } from './logger.mjs';
|
import { logger } from './logger.mjs';
|
||||||
|
|
@ -33,6 +33,7 @@ export const getSampleBufferSource = async (s, n, note, speed, freq, bank, resol
|
||||||
const ac = getAudioContext();
|
const ac = getAudioContext();
|
||||||
let sampleUrl;
|
let sampleUrl;
|
||||||
if (Array.isArray(bank)) {
|
if (Array.isArray(bank)) {
|
||||||
|
n = nanFallback(n, 0);
|
||||||
sampleUrl = bank[n % bank.length];
|
sampleUrl = bank[n % bank.length];
|
||||||
} else {
|
} else {
|
||||||
const midiDiff = (noteA) => noteToMidi(noteA) - midi;
|
const midiDiff = (noteA) => noteToMidi(noteA) - midi;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ This program is free software: you can redistribute it and/or modify it under th
|
||||||
import './feedbackdelay.mjs';
|
import './feedbackdelay.mjs';
|
||||||
import './reverb.mjs';
|
import './reverb.mjs';
|
||||||
import './vowel.mjs';
|
import './vowel.mjs';
|
||||||
import { clamp } from './util.mjs';
|
import { clamp, nanFallback } from './util.mjs';
|
||||||
import workletsUrl from './worklets.mjs?url';
|
import workletsUrl from './worklets.mjs?url';
|
||||||
import { createFilter, gainNode, getCompressor } from './helpers.mjs';
|
import { createFilter, gainNode, getCompressor } from './helpers.mjs';
|
||||||
import { map } from 'nanostores';
|
import { map } from 'nanostores';
|
||||||
|
|
@ -322,6 +322,7 @@ export const superdough = async (value, deadline, hapDuration) => {
|
||||||
compressorAttack,
|
compressorAttack,
|
||||||
compressorRelease,
|
compressorRelease,
|
||||||
} = value;
|
} = value;
|
||||||
|
gain = nanFallback(gain, 1);
|
||||||
|
|
||||||
//music programs/audio gear usually increments inputs/outputs from 1, so imitate that behavior
|
//music programs/audio gear usually increments inputs/outputs from 1, so imitate that behavior
|
||||||
channels = (Array.isArray(channels) ? channels : [channels]).map((ch) => ch - 1);
|
channels = (Array.isArray(channels) ? channels : [channels]).map((ch) => ch - 1);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { logger } from './logger.mjs';
|
||||||
|
|
||||||
// currently duplicate with core util.mjs to skip dependency
|
// currently duplicate with core util.mjs to skip dependency
|
||||||
// TODO: add separate util module?
|
// TODO: add separate util module?
|
||||||
|
|
||||||
|
|
@ -51,3 +53,11 @@ export const valueToMidi = (value, fallbackValue) => {
|
||||||
}
|
}
|
||||||
return fallbackValue;
|
return fallbackValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function nanFallback(value, fallback) {
|
||||||
|
if (isNaN(Number(value))) {
|
||||||
|
logger(`"${value}" is not a number, falling back to ${fallback}`, 'warning');
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,496 +0,0 @@
|
||||||
import XMarkIcon from '@heroicons/react/20/solid/XMarkIcon';
|
|
||||||
import { logger } from '@strudel.cycles/core';
|
|
||||||
import { useEvent, cx } from '@strudel.cycles/react';
|
|
||||||
// import { cx } from '@strudel.cycles/react';
|
|
||||||
import { nanoid } from 'nanoid';
|
|
||||||
import React, { useMemo, useCallback, useLayoutEffect, useRef, useState } from 'react';
|
|
||||||
import { Reference } from './Reference';
|
|
||||||
import { themes } from './themes.mjs';
|
|
||||||
import { useSettings, settingsMap, setActiveFooter, defaultSettings } from '../settings.mjs';
|
|
||||||
import { getAudioContext, soundMap, connectToDestination } from '@strudel.cycles/webaudio';
|
|
||||||
import { useStore } from '@nanostores/react';
|
|
||||||
import { FilesTab } from './FilesTab';
|
|
||||||
|
|
||||||
const TAURI = window.__TAURI__;
|
|
||||||
|
|
||||||
const { BASE_URL } = import.meta.env;
|
|
||||||
const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
|
|
||||||
|
|
||||||
export function Footer({ context }) {
|
|
||||||
const footerContent = useRef();
|
|
||||||
const [log, setLog] = useState([]);
|
|
||||||
const { activeFooter, isZen, panelPosition } = useSettings();
|
|
||||||
|
|
||||||
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;
|
|
||||||
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 }) => (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
onClick={() => setActiveFooter(name)}
|
|
||||||
className={cx(
|
|
||||||
'h-8 px-2 text-foreground cursor-pointer hover:opacity-50 flex items-center space-x-1 border-b',
|
|
||||||
activeFooter === name ? 'border-foreground' : 'border-transparent',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{label || name}
|
|
||||||
</div>
|
|
||||||
{activeFooter === name && <>{children}</>}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
if (isZen) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isActive = activeFooter !== '';
|
|
||||||
|
|
||||||
let positions = {
|
|
||||||
right: cx('max-w-full flex-grow-0 flex-none overflow-hidden', isActive ? 'w-[600px] h-full' : 'absolute right-0'),
|
|
||||||
bottom: cx('relative', isActive ? 'h-[360px] min-h-[360px]' : ''),
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<nav className={cx('bg-lineHighlight z-[10] flex flex-col', positions[panelPosition])}>
|
|
||||||
<div className="flex justify-between px-2">
|
|
||||||
<div className={cx('flex select-none max-w-full overflow-auto', activeFooter && 'pb-2')}>
|
|
||||||
<FooterTab name="intro" label="welcome" />
|
|
||||||
<FooterTab name="sounds" />
|
|
||||||
<FooterTab name="console" />
|
|
||||||
<FooterTab name="reference" />
|
|
||||||
<FooterTab name="settings" />
|
|
||||||
{TAURI && <FooterTab name="files" />}
|
|
||||||
</div>
|
|
||||||
{activeFooter !== '' && (
|
|
||||||
<button onClick={() => setActiveFooter('')} className="text-foreground px-2" aria-label="Close Panel">
|
|
||||||
<XMarkIcon className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{activeFooter !== '' && (
|
|
||||||
<div className="relative overflow-hidden">
|
|
||||||
<div className="text-white overflow-auto h-full max-w-full" ref={footerContent}>
|
|
||||||
{activeFooter === 'intro' && <WelcomeTab />}
|
|
||||||
{activeFooter === 'console' && <ConsoleTab log={log} />}
|
|
||||||
{activeFooter === 'sounds' && <SoundsTab />}
|
|
||||||
{activeFooter === 'reference' && <Reference />}
|
|
||||||
{activeFooter === 'settings' && <SettingsTab scheduler={context.scheduler} />}
|
|
||||||
{activeFooter === 'files' && <FilesTab />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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, '<a class="underline" href="$1" target="_blank">$1</a>');
|
|
||||||
|
|
||||||
//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<a class="underline" href="http://$2" target="_blank">$2</a>',
|
|
||||||
);
|
|
||||||
|
|
||||||
//Change email addresses to mailto:: links.
|
|
||||||
replacePattern3 = /(([a-zA-Z0-9\-\_\.])+@[a-zA-Z\_]+?(\.[a-zA-Z]{2,6})+)/gim;
|
|
||||||
replacedText = replacedText.replace(replacePattern3, '<a class="underline" href="mailto:$1">$1</a>');
|
|
||||||
|
|
||||||
return replacedText;
|
|
||||||
}
|
|
||||||
|
|
||||||
function WelcomeTab() {
|
|
||||||
return (
|
|
||||||
<div className="prose dark:prose-invert max-w-[600px] pt-2 font-sans pb-8 px-4">
|
|
||||||
<h3>
|
|
||||||
<span className={cx('animate-spin inline-block select-none')}>🌀</span> welcome
|
|
||||||
</h3>
|
|
||||||
<p>
|
|
||||||
You have found <span className="underline">strudel</span>, 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:
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<span className="underline">1. hit play</span> - <span className="underline">2. change something</span> -{' '}
|
|
||||||
<span className="underline">3. hit update</span>
|
|
||||||
<br />
|
|
||||||
If you don't like what you hear, try <span className="underline">shuffle</span>!
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
To learn more about what this all means, check out the{' '}
|
|
||||||
<a href={`${baseNoTrailing}/workshop/getting-started/`} target="_blank">
|
|
||||||
interactive tutorial
|
|
||||||
</a>
|
|
||||||
. Also feel free to join the{' '}
|
|
||||||
<a href="https://discord.com/invite/HGEdXmRkzT" target="_blank">
|
|
||||||
tidalcycles discord channel
|
|
||||||
</a>{' '}
|
|
||||||
to ask any questions, give feedback or just say hello.
|
|
||||||
</p>
|
|
||||||
<h3>about</h3>
|
|
||||||
<p>
|
|
||||||
strudel is a JavaScript version of{' '}
|
|
||||||
<a href="https://tidalcycles.org/" target="_blank">
|
|
||||||
tidalcycles
|
|
||||||
</a>
|
|
||||||
, which is a popular live coding language for music, written in Haskell. You can find the source code at{' '}
|
|
||||||
<a href="https://github.com/tidalcycles/strudel" target="_blank">
|
|
||||||
github
|
|
||||||
</a>
|
|
||||||
. Please consider to{' '}
|
|
||||||
<a href="https://opencollective.com/tidalcycles" target="_blank">
|
|
||||||
support this project
|
|
||||||
</a>{' '}
|
|
||||||
to ensure ongoing development 💖
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConsoleTab({ log }) {
|
|
||||||
return (
|
|
||||||
<div id="console-tab" className="break-all px-4 dark:text-white text-stone-900 text-sm">
|
|
||||||
<pre>{`███████╗████████╗██████╗ ██╗ ██╗██████╗ ███████╗██╗
|
|
||||||
██╔════╝╚══██╔══╝██╔══██╗██║ ██║██╔══██╗██╔════╝██║
|
|
||||||
███████╗ ██║ ██████╔╝██║ ██║██║ ██║█████╗ ██║
|
|
||||||
╚════██║ ██║ ██╔══██╗██║ ██║██║ ██║██╔══╝ ██║
|
|
||||||
███████║ ██║ ██║ ██║╚██████╔╝██████╔╝███████╗███████╗
|
|
||||||
╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝`}</pre>
|
|
||||||
{log.map((l, i) => {
|
|
||||||
const message = linkify(l.message);
|
|
||||||
return (
|
|
||||||
<div key={l.id} className={cx(l.type === 'error' && 'text-red-500', l.type === 'highlight' && 'underline')}>
|
|
||||||
<span dangerouslySetInnerHTML={{ __html: message }} />
|
|
||||||
{l.count ? ` (${l.count})` : ''}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const getSamples = (samples) =>
|
|
||||||
Array.isArray(samples) ? samples.length : typeof samples === 'object' ? Object.values(samples).length : 1;
|
|
||||||
|
|
||||||
function SoundsTab() {
|
|
||||||
const sounds = useStore(soundMap);
|
|
||||||
const { soundsFilter } = useSettings();
|
|
||||||
const soundEntries = useMemo(() => {
|
|
||||||
let filtered = Object.entries(sounds).filter(([key]) => !key.startsWith('_'));
|
|
||||||
if (!sounds) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
if (soundsFilter === 'user') {
|
|
||||||
return filtered.filter(([key, { data }]) => !data.prebake);
|
|
||||||
}
|
|
||||||
if (soundsFilter === 'drums') {
|
|
||||||
return filtered.filter(([_, { data }]) => data.type === 'sample' && data.tag === 'drum-machines');
|
|
||||||
}
|
|
||||||
if (soundsFilter === 'samples') {
|
|
||||||
return filtered.filter(([_, { data }]) => data.type === 'sample' && data.tag !== 'drum-machines');
|
|
||||||
}
|
|
||||||
if (soundsFilter === 'synths') {
|
|
||||||
return filtered.filter(([_, { data }]) => ['synth', 'soundfont'].includes(data.type));
|
|
||||||
}
|
|
||||||
return filtered;
|
|
||||||
}, [sounds, soundsFilter]);
|
|
||||||
// holds mutable ref to current triggered sound
|
|
||||||
const trigRef = useRef();
|
|
||||||
// stop current sound on mouseup
|
|
||||||
useEvent('mouseup', () => {
|
|
||||||
const t = trigRef.current;
|
|
||||||
trigRef.current = undefined;
|
|
||||||
t?.then((ref) => {
|
|
||||||
ref?.stop(getAudioContext().currentTime + 0.01);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<div id="sounds-tab" className="flex flex-col w-full h-full dark:text-white text-stone-900">
|
|
||||||
<div className="px-2 pb-2 flex-none">
|
|
||||||
<ButtonGroup
|
|
||||||
value={soundsFilter}
|
|
||||||
onChange={(value) => settingsMap.setKey('soundsFilter', value)}
|
|
||||||
items={{
|
|
||||||
samples: 'samples',
|
|
||||||
drums: 'drum-machines',
|
|
||||||
synths: 'Synths',
|
|
||||||
user: 'User',
|
|
||||||
}}
|
|
||||||
></ButtonGroup>
|
|
||||||
</div>
|
|
||||||
<div className="p-2 min-h-0 max-h-full grow overflow-auto font-mono text-sm break-normal">
|
|
||||||
{soundEntries.map(([name, { data, onTrigger }]) => (
|
|
||||||
<span
|
|
||||||
key={name}
|
|
||||||
className="cursor-pointer hover:opacity-50"
|
|
||||||
onMouseDown={async () => {
|
|
||||||
const ctx = getAudioContext();
|
|
||||||
const params = {
|
|
||||||
note: ['synth', 'soundfont'].includes(data.type) ? 'a3' : undefined,
|
|
||||||
s: name,
|
|
||||||
clip: 1,
|
|
||||||
release: 0.5,
|
|
||||||
};
|
|
||||||
const time = ctx.currentTime + 0.05;
|
|
||||||
const onended = () => trigRef.current?.node?.disconnect();
|
|
||||||
trigRef.current = Promise.resolve(onTrigger(time, params, onended));
|
|
||||||
trigRef.current.then((ref) => {
|
|
||||||
connectToDestination(ref?.node);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{' '}
|
|
||||||
{name}
|
|
||||||
{data?.type === 'sample' ? `(${getSamples(data.samples)})` : ''}
|
|
||||||
{data?.type === 'soundfont' ? `(${data.fonts.length})` : ''}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{!soundEntries.length ? 'No custom sounds loaded in this pattern (yet).' : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Checkbox({ label, value, onChange }) {
|
|
||||||
return (
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" checked={value} onChange={onChange} />
|
|
||||||
{' ' + label}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ButtonGroup({ value, onChange, items }) {
|
|
||||||
return (
|
|
||||||
<div className="flex max-w-lg">
|
|
||||||
{Object.entries(items).map(([key, label], i, arr) => (
|
|
||||||
<button
|
|
||||||
key={key}
|
|
||||||
onClick={() => onChange(key)}
|
|
||||||
className={cx(
|
|
||||||
'px-2 border-b h-8',
|
|
||||||
// i === 0 && 'rounded-l-md',
|
|
||||||
// i === arr.length - 1 && 'rounded-r-md',
|
|
||||||
// value === key ? 'bg-background' : 'bg-lineHighlight',
|
|
||||||
value === key ? 'border-foreground' : 'border-transparent',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{label.toLowerCase()}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectInput({ value, options, onChange }) {
|
|
||||||
return (
|
|
||||||
<select
|
|
||||||
className="p-2 bg-background rounded-md text-foreground"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
>
|
|
||||||
{Object.entries(options).map(([k, label]) => (
|
|
||||||
<option key={k} className="bg-background" value={k}>
|
|
||||||
{label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NumberSlider({ value, onChange, step = 1, ...rest }) {
|
|
||||||
return (
|
|
||||||
<div className="flex space-x-2 gap-1">
|
|
||||||
<input
|
|
||||||
className="p-2 grow"
|
|
||||||
type="range"
|
|
||||||
value={value}
|
|
||||||
step={step}
|
|
||||||
onChange={(e) => onChange(Number(e.target.value))}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={value}
|
|
||||||
step={step}
|
|
||||||
className="w-16 bg-background rounded-md"
|
|
||||||
onChange={(e) => onChange(Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FormItem({ label, children }) {
|
|
||||||
return (
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<label>{label}</label>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const themeOptions = Object.fromEntries(Object.keys(themes).map((k) => [k, k]));
|
|
||||||
const fontFamilyOptions = {
|
|
||||||
monospace: 'monospace',
|
|
||||||
BigBlueTerminal: 'BigBlueTerminal',
|
|
||||||
x3270: 'x3270',
|
|
||||||
PressStart: 'PressStart2P',
|
|
||||||
galactico: 'galactico',
|
|
||||||
'we-come-in-peace': 'we-come-in-peace',
|
|
||||||
FiraCode: 'FiraCode',
|
|
||||||
'FiraCode-SemiBold': 'FiraCode SemiBold',
|
|
||||||
teletext: 'teletext',
|
|
||||||
mode7: 'mode7',
|
|
||||||
};
|
|
||||||
|
|
||||||
function SettingsTab({ scheduler }) {
|
|
||||||
const {
|
|
||||||
theme,
|
|
||||||
keybindings,
|
|
||||||
isLineNumbersDisplayed,
|
|
||||||
isActiveLineHighlighted,
|
|
||||||
isAutoCompletionEnabled,
|
|
||||||
isTooltipEnabled,
|
|
||||||
isLineWrappingEnabled,
|
|
||||||
fontSize,
|
|
||||||
fontFamily,
|
|
||||||
panelPosition,
|
|
||||||
} = useSettings();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="text-foreground p-4 space-y-4">
|
|
||||||
{/* <FormItem label="Tempo">
|
|
||||||
<div className="space-x-4">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
scheduler.setCps(scheduler.cps - 0.1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
slower
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
scheduler.setCps(scheduler.cps + 0.1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
faster
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</FormItem> */}
|
|
||||||
<FormItem label="Theme">
|
|
||||||
<SelectInput options={themeOptions} value={theme} onChange={(theme) => settingsMap.setKey('theme', theme)} />
|
|
||||||
</FormItem>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<FormItem label="Font Family">
|
|
||||||
<SelectInput
|
|
||||||
options={fontFamilyOptions}
|
|
||||||
value={fontFamily}
|
|
||||||
onChange={(fontFamily) => settingsMap.setKey('fontFamily', fontFamily)}
|
|
||||||
/>
|
|
||||||
</FormItem>
|
|
||||||
<FormItem label="Font Size">
|
|
||||||
<NumberSlider
|
|
||||||
value={fontSize}
|
|
||||||
onChange={(fontSize) => settingsMap.setKey('fontSize', fontSize)}
|
|
||||||
min={10}
|
|
||||||
max={40}
|
|
||||||
step={2}
|
|
||||||
/>
|
|
||||||
</FormItem>
|
|
||||||
</div>
|
|
||||||
<FormItem label="Keybindings">
|
|
||||||
<ButtonGroup
|
|
||||||
value={keybindings}
|
|
||||||
onChange={(keybindings) => settingsMap.setKey('keybindings', keybindings)}
|
|
||||||
items={{ codemirror: 'Codemirror', vim: 'Vim', emacs: 'Emacs', vscode: 'VSCode' }}
|
|
||||||
></ButtonGroup>
|
|
||||||
</FormItem>
|
|
||||||
<FormItem label="Panel Position">
|
|
||||||
<ButtonGroup
|
|
||||||
value={panelPosition}
|
|
||||||
onChange={(value) => settingsMap.setKey('panelPosition', value)}
|
|
||||||
items={{ bottom: 'Bottom', right: 'Right' }}
|
|
||||||
></ButtonGroup>
|
|
||||||
</FormItem>
|
|
||||||
<FormItem label="Code Settings">
|
|
||||||
<Checkbox
|
|
||||||
label="Display line numbers"
|
|
||||||
onChange={(cbEvent) => settingsMap.setKey('isLineNumbersDisplayed', cbEvent.target.checked)}
|
|
||||||
value={isLineNumbersDisplayed}
|
|
||||||
/>
|
|
||||||
<Checkbox
|
|
||||||
label="Highlight active line"
|
|
||||||
onChange={(cbEvent) => settingsMap.setKey('isActiveLineHighlighted', cbEvent.target.checked)}
|
|
||||||
value={isActiveLineHighlighted}
|
|
||||||
/>
|
|
||||||
<Checkbox
|
|
||||||
label="Enable auto-completion"
|
|
||||||
onChange={(cbEvent) => settingsMap.setKey('isAutoCompletionEnabled', cbEvent.target.checked)}
|
|
||||||
value={isAutoCompletionEnabled}
|
|
||||||
/>
|
|
||||||
<Checkbox
|
|
||||||
label="Enable tooltips on Ctrl and hover"
|
|
||||||
onChange={(cbEvent) => settingsMap.setKey('isTooltipEnabled', cbEvent.target.checked)}
|
|
||||||
value={isTooltipEnabled}
|
|
||||||
/>
|
|
||||||
<Checkbox
|
|
||||||
label="Enable line wrapping"
|
|
||||||
onChange={(cbEvent) => settingsMap.setKey('isLineWrappingEnabled', cbEvent.target.checked)}
|
|
||||||
value={isLineWrappingEnabled}
|
|
||||||
/>
|
|
||||||
</FormItem>
|
|
||||||
<FormItem label="Zen Mode">Try clicking the logo in the top left!</FormItem>
|
|
||||||
<FormItem label="Reset Settings">
|
|
||||||
<button
|
|
||||||
className="bg-background p-2 max-w-[300px] rounded-md hover:opacity-50"
|
|
||||||
onClick={() => {
|
|
||||||
if (confirm('Sure?')) {
|
|
||||||
settingsMap.set(defaultSettings);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
restore default settings
|
|
||||||
</button>
|
|
||||||
</FormItem>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -11,13 +11,13 @@ import { createClient } from '@supabase/supabase-js';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import React, { createContext, useCallback, useEffect, useState, useMemo } from 'react';
|
import React, { createContext, useCallback, useEffect, useState, useMemo } from 'react';
|
||||||
import './Repl.css';
|
import './Repl.css';
|
||||||
import { Footer } from './Footer';
|
import { Panel } from './panel/Panel';
|
||||||
import { Header } from './Header';
|
import { Header } from './Header';
|
||||||
import { prebake } from './prebake.mjs';
|
import { prebake } from './prebake.mjs';
|
||||||
import * as tunes from './tunes.mjs';
|
import * as tunes from './tunes.mjs';
|
||||||
import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon';
|
import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon';
|
||||||
import { themes } from './themes.mjs';
|
import { themes } from './themes.mjs';
|
||||||
import { settingsMap, useSettings, setLatestCode } from '../settings.mjs';
|
import { settingsMap, useSettings, setLatestCode, updateUserCode } from '../settings.mjs';
|
||||||
import Loader from './Loader';
|
import Loader from './Loader';
|
||||||
import { settingPatterns } from '../settings.mjs';
|
import { settingPatterns } from '../settings.mjs';
|
||||||
import { code2hash, hash2code } from './helpers.mjs';
|
import { code2hash, hash2code } from './helpers.mjs';
|
||||||
|
|
@ -131,6 +131,7 @@ export function Repl({ embedded = false }) {
|
||||||
isLineWrappingEnabled,
|
isLineWrappingEnabled,
|
||||||
panelPosition,
|
panelPosition,
|
||||||
isZen,
|
isZen,
|
||||||
|
activePattern,
|
||||||
} = useSettings();
|
} = useSettings();
|
||||||
|
|
||||||
const paintOptions = useMemo(() => ({ fontFamily }), [fontFamily]);
|
const paintOptions = useMemo(() => ({ fontFamily }), [fontFamily]);
|
||||||
|
|
@ -147,6 +148,7 @@ export function Repl({ embedded = false }) {
|
||||||
cleanupDraw();
|
cleanupDraw();
|
||||||
},
|
},
|
||||||
afterEval: ({ code, meta }) => {
|
afterEval: ({ code, meta }) => {
|
||||||
|
updateUserCode(code);
|
||||||
setMiniLocations(meta.miniLocations);
|
setMiniLocations(meta.miniLocations);
|
||||||
setWidgets(meta.widgets);
|
setWidgets(meta.widgets);
|
||||||
setPending(false);
|
setPending(false);
|
||||||
|
|
@ -226,7 +228,7 @@ export function Repl({ embedded = false }) {
|
||||||
const handleChangeCode = useCallback(
|
const handleChangeCode = useCallback(
|
||||||
(c) => {
|
(c) => {
|
||||||
setCode(c);
|
setCode(c);
|
||||||
//started && logger('[edit] code changed. hit ctrl+enter to update');
|
// started && logger('[edit] code changed. hit ctrl+enter to update');
|
||||||
},
|
},
|
||||||
[started],
|
[started],
|
||||||
);
|
);
|
||||||
|
|
@ -245,8 +247,8 @@ export function Repl({ embedded = false }) {
|
||||||
stop();
|
stop();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleUpdate = () => {
|
const handleUpdate = (newCode) => {
|
||||||
isDirty && activateCode();
|
(newCode || isDirty) && activateCode(newCode);
|
||||||
logger('[repl] code updated! tip: you can also update the code by pressing ctrl+enter', 'highlight');
|
logger('[repl] code updated! tip: you can also update the code by pressing ctrl+enter', 'highlight');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -347,12 +349,12 @@ export function Repl({ embedded = false }) {
|
||||||
onSelectionChange={handleSelectionChange}
|
onSelectionChange={handleSelectionChange}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
{panelPosition === 'right' && !isEmbedded && <Footer context={context} />}
|
{panelPosition === 'right' && !isEmbedded && <Panel context={context} />}
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-red-500 p-4 bg-lineHighlight animate-pulse">{error.message || 'Unknown Error :-/'}</div>
|
<div className="text-red-500 p-4 bg-lineHighlight animate-pulse">{error.message || 'Unknown Error :-/'}</div>
|
||||||
)}
|
)}
|
||||||
{panelPosition === 'bottom' && !isEmbedded && <Footer context={context} />}
|
{panelPosition === 'bottom' && !isEmbedded && <Panel context={context} />}
|
||||||
</div>
|
</div>
|
||||||
</ReplContext.Provider>
|
</ReplContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
45
website/src/repl/panel/ConsoleTab.jsx
Normal file
45
website/src/repl/panel/ConsoleTab.jsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { cx } from '@strudel.cycles/react';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export function ConsoleTab({ log }) {
|
||||||
|
return (
|
||||||
|
<div id="console-tab" className="break-all px-4 dark:text-white text-stone-900 text-sm">
|
||||||
|
<pre>{`███████╗████████╗██████╗ ██╗ ██╗██████╗ ███████╗██╗
|
||||||
|
██╔════╝╚══██╔══╝██╔══██╗██║ ██║██╔══██╗██╔════╝██║
|
||||||
|
███████╗ ██║ ██████╔╝██║ ██║██║ ██║█████╗ ██║
|
||||||
|
╚════██║ ██║ ██╔══██╗██║ ██║██║ ██║██╔══╝ ██║
|
||||||
|
███████║ ██║ ██║ ██║╚██████╔╝██████╔╝███████╗███████╗
|
||||||
|
╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝`}</pre>
|
||||||
|
{log.map((l, i) => {
|
||||||
|
const message = linkify(l.message);
|
||||||
|
return (
|
||||||
|
<div key={l.id} className={cx(l.type === 'error' && 'text-red-500', l.type === 'highlight' && 'underline')}>
|
||||||
|
<span dangerouslySetInnerHTML={{ __html: message }} />
|
||||||
|
{l.count ? ` (${l.count})` : ''}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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, '<a class="underline" href="$1" target="_blank">$1</a>');
|
||||||
|
|
||||||
|
//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<a class="underline" href="http://$2" target="_blank">$2</a>',
|
||||||
|
);
|
||||||
|
|
||||||
|
//Change email addresses to mailto:: links.
|
||||||
|
replacePattern3 = /(([a-zA-Z0-9\-\_\.])+@[a-zA-Z\_]+?(\.[a-zA-Z]{2,6})+)/gim;
|
||||||
|
replacedText = replacedText.replace(replacePattern3, '<a class="underline" href="mailto:$1">$1</a>');
|
||||||
|
|
||||||
|
return replacedText;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Fragment, useEffect } from 'react';
|
import { Fragment, useEffect } from 'react';
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { isAudioFile, readDir, dir, playFile } from './files.mjs';
|
import { isAudioFile, readDir, dir, playFile } from '../files.mjs';
|
||||||
|
|
||||||
export function FilesTab() {
|
export function FilesTab() {
|
||||||
const [path, setPath] = useState([]);
|
const [path, setPath] = useState([]);
|
||||||
24
website/src/repl/panel/Forms.jsx
Normal file
24
website/src/repl/panel/Forms.jsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { cx } from '@strudel.cycles/react';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export function ButtonGroup({ value, onChange, items }) {
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-lg">
|
||||||
|
{Object.entries(items).map(([key, label], i, arr) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => onChange(key)}
|
||||||
|
className={cx(
|
||||||
|
'px-2 border-b h-8',
|
||||||
|
// i === 0 && 'rounded-l-md',
|
||||||
|
// i === arr.length - 1 && 'rounded-r-md',
|
||||||
|
// value === key ? 'bg-background' : 'bg-lineHighlight',
|
||||||
|
value === key ? 'border-foreground' : 'border-transparent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label.toLowerCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
website/src/repl/panel/Panel.jsx
Normal file
119
website/src/repl/panel/Panel.jsx
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import XMarkIcon from '@heroicons/react/20/solid/XMarkIcon';
|
||||||
|
import { logger } from '@strudel.cycles/core';
|
||||||
|
import { cx, useEvent } from '@strudel.cycles/react';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import React, { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
import { setActiveFooter, useSettings } from '../../settings.mjs';
|
||||||
|
import { ConsoleTab } from './ConsoleTab';
|
||||||
|
import { FilesTab } from './FilesTab';
|
||||||
|
import { Reference } from './Reference';
|
||||||
|
import { SettingsTab } from './SettingsTab';
|
||||||
|
import { SoundsTab } from './SoundsTab';
|
||||||
|
import { WelcomeTab } from './WelcomeTab';
|
||||||
|
import { PatternsTab } from './PatternsTab';
|
||||||
|
|
||||||
|
const TAURI = window.__TAURI__;
|
||||||
|
|
||||||
|
export function Panel({ context }) {
|
||||||
|
const footerContent = useRef();
|
||||||
|
const [log, setLog] = useState([]);
|
||||||
|
const { activeFooter, isZen, panelPosition } = useSettings();
|
||||||
|
|
||||||
|
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;
|
||||||
|
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 PanelTab = ({ children, name, label }) => (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
onClick={() => setActiveFooter(name)}
|
||||||
|
className={cx(
|
||||||
|
'h-8 px-2 text-foreground cursor-pointer hover:opacity-50 flex items-center space-x-1 border-b',
|
||||||
|
activeFooter === name ? 'border-foreground' : 'border-transparent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label || name}
|
||||||
|
</div>
|
||||||
|
{activeFooter === name && <>{children}</>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
if (isZen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActive = activeFooter !== '';
|
||||||
|
|
||||||
|
let positions = {
|
||||||
|
right: cx('max-w-full flex-grow-0 flex-none overflow-hidden', isActive ? 'w-[600px] h-full' : 'absolute right-0'),
|
||||||
|
bottom: cx('relative', isActive ? 'h-[360px] min-h-[360px]' : ''),
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<nav className={cx('bg-lineHighlight z-[10] flex flex-col', positions[panelPosition])}>
|
||||||
|
<div className="flex justify-between px-2">
|
||||||
|
<div className={cx('flex select-none max-w-full overflow-auto', activeFooter && 'pb-2')}>
|
||||||
|
<PanelTab name="intro" label="welcome" />
|
||||||
|
<PanelTab name="patterns" />
|
||||||
|
<PanelTab name="sounds" />
|
||||||
|
<PanelTab name="console" />
|
||||||
|
<PanelTab name="reference" />
|
||||||
|
<PanelTab name="settings" />
|
||||||
|
{TAURI && <PanelTab name="files" />}
|
||||||
|
</div>
|
||||||
|
{activeFooter !== '' && (
|
||||||
|
<button onClick={() => setActiveFooter('')} className="text-foreground px-2" aria-label="Close Panel">
|
||||||
|
<XMarkIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{activeFooter !== '' && (
|
||||||
|
<div className="relative overflow-hidden">
|
||||||
|
<div className="text-white overflow-auto h-full max-w-full" ref={footerContent}>
|
||||||
|
{activeFooter === 'intro' && <WelcomeTab context={context} />}
|
||||||
|
{activeFooter === 'patterns' && <PatternsTab context={context} />}
|
||||||
|
{activeFooter === 'console' && <ConsoleTab log={log} />}
|
||||||
|
{activeFooter === 'sounds' && <SoundsTab />}
|
||||||
|
{activeFooter === 'reference' && <Reference />}
|
||||||
|
{activeFooter === 'settings' && <SettingsTab scheduler={context.scheduler} />}
|
||||||
|
{activeFooter === 'files' && <FilesTab />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useLogger(onTrigger) {
|
||||||
|
useEvent(logger.key, onTrigger);
|
||||||
|
}
|
||||||
93
website/src/repl/panel/PatternsTab.jsx
Normal file
93
website/src/repl/panel/PatternsTab.jsx
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import React from 'react';
|
||||||
|
import * as tunes from '../tunes.mjs';
|
||||||
|
import {
|
||||||
|
useSettings,
|
||||||
|
clearUserPatterns,
|
||||||
|
newUserPattern,
|
||||||
|
setActivePattern,
|
||||||
|
deleteActivePattern,
|
||||||
|
duplicateActivePattern,
|
||||||
|
getUserPattern,
|
||||||
|
renameActivePattern,
|
||||||
|
} from '../../settings.mjs';
|
||||||
|
|
||||||
|
function classNames(...classes) {
|
||||||
|
return classes.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PatternsTab({ context }) {
|
||||||
|
const { userPatterns, activePattern } = useSettings();
|
||||||
|
return (
|
||||||
|
<div className="px-4 w-full text-foreground space-y-4">
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl mb-2">Pattern Collection</h2>
|
||||||
|
<div className="space-x-4 border-b border-foreground mb-1">
|
||||||
|
<button
|
||||||
|
className="hover:opacity-50"
|
||||||
|
onClick={() => {
|
||||||
|
const name = newUserPattern();
|
||||||
|
const { code } = getUserPattern(name);
|
||||||
|
context.handleUpdate(code);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
new
|
||||||
|
</button>
|
||||||
|
<button className="hover:opacity-50" onClick={() => duplicateActivePattern()}>
|
||||||
|
duplicate
|
||||||
|
</button>
|
||||||
|
<button className="hover:opacity-50" onClick={() => renameActivePattern()}>
|
||||||
|
rename
|
||||||
|
</button>
|
||||||
|
<button className="hover:opacity-50" onClick={() => deleteActivePattern()}>
|
||||||
|
delete
|
||||||
|
</button>
|
||||||
|
<button className="hover:opacity-50" onClick={() => clearUserPatterns()}>
|
||||||
|
clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{Object.entries(userPatterns).map(([key, up]) => (
|
||||||
|
<a
|
||||||
|
key={key}
|
||||||
|
className={classNames(
|
||||||
|
'mr-4 hover:opacity-50 cursor-pointer inline-block',
|
||||||
|
key === activePattern ? 'underline' : '',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
const { code } = up;
|
||||||
|
setActivePattern(key);
|
||||||
|
context.handleUpdate(code);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{key}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl mb-2">Examples</h2>
|
||||||
|
{Object.entries(tunes).map(([key, tune]) => (
|
||||||
|
<a
|
||||||
|
key={key}
|
||||||
|
className={classNames(
|
||||||
|
'mr-4 hover:opacity-50 cursor-pointer inline-block',
|
||||||
|
key === activePattern ? 'underline' : '',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setActivePattern(key);
|
||||||
|
context.handleUpdate(tune);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{key}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
selectable examples
|
||||||
|
if example selected
|
||||||
|
type character -> create new user pattern with exampleName_n
|
||||||
|
even if
|
||||||
|
clicking (+) opens the "new" example with same behavior as above
|
||||||
|
*/
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import jsdocJson from '../../../doc.json';
|
import jsdocJson from '../../../../doc.json';
|
||||||
const visibleFunctions = jsdocJson.docs
|
const visibleFunctions = jsdocJson.docs
|
||||||
.filter(({ name, description }) => name && !name.startsWith('_') && !!description)
|
.filter(({ name, description }) => name && !name.startsWith('_') && !!description)
|
||||||
.sort((a, b) => /* a.meta.filename.localeCompare(b.meta.filename) + */ a.name.localeCompare(b.name));
|
.sort((a, b) => /* a.meta.filename.localeCompare(b.meta.filename) + */ a.name.localeCompare(b.name));
|
||||||
187
website/src/repl/panel/SettingsTab.jsx
Normal file
187
website/src/repl/panel/SettingsTab.jsx
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { defaultSettings, settingsMap, useSettings } from '../../settings.mjs';
|
||||||
|
import { themes } from '../themes.mjs';
|
||||||
|
import { ButtonGroup } from './Forms.jsx';
|
||||||
|
|
||||||
|
function Checkbox({ label, value, onChange }) {
|
||||||
|
return (
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" checked={value} onChange={onChange} />
|
||||||
|
{' ' + label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectInput({ value, options, onChange }) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
className="p-2 bg-background rounded-md text-foreground"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
>
|
||||||
|
{Object.entries(options).map(([k, label]) => (
|
||||||
|
<option key={k} className="bg-background" value={k}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberSlider({ value, onChange, step = 1, ...rest }) {
|
||||||
|
return (
|
||||||
|
<div className="flex space-x-2 gap-1">
|
||||||
|
<input
|
||||||
|
className="p-2 grow"
|
||||||
|
type="range"
|
||||||
|
value={value}
|
||||||
|
step={step}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
step={step}
|
||||||
|
className="w-16 bg-background rounded-md"
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormItem({ label, children }) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label>{label}</label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeOptions = Object.fromEntries(Object.keys(themes).map((k) => [k, k]));
|
||||||
|
const fontFamilyOptions = {
|
||||||
|
monospace: 'monospace',
|
||||||
|
BigBlueTerminal: 'BigBlueTerminal',
|
||||||
|
x3270: 'x3270',
|
||||||
|
PressStart: 'PressStart2P',
|
||||||
|
galactico: 'galactico',
|
||||||
|
'we-come-in-peace': 'we-come-in-peace',
|
||||||
|
FiraCode: 'FiraCode',
|
||||||
|
'FiraCode-SemiBold': 'FiraCode SemiBold',
|
||||||
|
teletext: 'teletext',
|
||||||
|
mode7: 'mode7',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SettingsTab() {
|
||||||
|
const {
|
||||||
|
theme,
|
||||||
|
keybindings,
|
||||||
|
isLineNumbersDisplayed,
|
||||||
|
isActiveLineHighlighted,
|
||||||
|
isAutoCompletionEnabled,
|
||||||
|
isTooltipEnabled,
|
||||||
|
isLineWrappingEnabled,
|
||||||
|
fontSize,
|
||||||
|
fontFamily,
|
||||||
|
panelPosition,
|
||||||
|
} = useSettings();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-foreground p-4 space-y-4">
|
||||||
|
{/* <FormItem label="Tempo">
|
||||||
|
<div className="space-x-4">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
scheduler.setCps(scheduler.cps - 0.1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
slower
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
scheduler.setCps(scheduler.cps + 0.1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
faster
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</FormItem> */}
|
||||||
|
<FormItem label="Theme">
|
||||||
|
<SelectInput options={themeOptions} value={theme} onChange={(theme) => settingsMap.setKey('theme', theme)} />
|
||||||
|
</FormItem>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<FormItem label="Font Family">
|
||||||
|
<SelectInput
|
||||||
|
options={fontFamilyOptions}
|
||||||
|
value={fontFamily}
|
||||||
|
onChange={(fontFamily) => settingsMap.setKey('fontFamily', fontFamily)}
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="Font Size">
|
||||||
|
<NumberSlider
|
||||||
|
value={fontSize}
|
||||||
|
onChange={(fontSize) => settingsMap.setKey('fontSize', fontSize)}
|
||||||
|
min={10}
|
||||||
|
max={40}
|
||||||
|
step={2}
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
</div>
|
||||||
|
<FormItem label="Keybindings">
|
||||||
|
<ButtonGroup
|
||||||
|
value={keybindings}
|
||||||
|
onChange={(keybindings) => settingsMap.setKey('keybindings', keybindings)}
|
||||||
|
items={{ codemirror: 'Codemirror', vim: 'Vim', emacs: 'Emacs', vscode: 'VSCode' }}
|
||||||
|
></ButtonGroup>
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="Panel Position">
|
||||||
|
<ButtonGroup
|
||||||
|
value={panelPosition}
|
||||||
|
onChange={(value) => settingsMap.setKey('panelPosition', value)}
|
||||||
|
items={{ bottom: 'Bottom', right: 'Right' }}
|
||||||
|
></ButtonGroup>
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="Code Settings">
|
||||||
|
<Checkbox
|
||||||
|
label="Display line numbers"
|
||||||
|
onChange={(cbEvent) => settingsMap.setKey('isLineNumbersDisplayed', cbEvent.target.checked)}
|
||||||
|
value={isLineNumbersDisplayed}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
label="Highlight active line"
|
||||||
|
onChange={(cbEvent) => settingsMap.setKey('isActiveLineHighlighted', cbEvent.target.checked)}
|
||||||
|
value={isActiveLineHighlighted}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
label="Enable auto-completion"
|
||||||
|
onChange={(cbEvent) => settingsMap.setKey('isAutoCompletionEnabled', cbEvent.target.checked)}
|
||||||
|
value={isAutoCompletionEnabled}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
label="Enable tooltips on Ctrl and hover"
|
||||||
|
onChange={(cbEvent) => settingsMap.setKey('isTooltipEnabled', cbEvent.target.checked)}
|
||||||
|
value={isTooltipEnabled}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
label="Enable line wrapping"
|
||||||
|
onChange={(cbEvent) => settingsMap.setKey('isLineWrappingEnabled', cbEvent.target.checked)}
|
||||||
|
value={isLineWrappingEnabled}
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="Zen Mode">Try clicking the logo in the top left!</FormItem>
|
||||||
|
<FormItem label="Reset Settings">
|
||||||
|
<button
|
||||||
|
className="bg-background p-2 max-w-[300px] rounded-md hover:opacity-50"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('Sure?')) {
|
||||||
|
settingsMap.set(defaultSettings);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
restore default settings
|
||||||
|
</button>
|
||||||
|
</FormItem>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
website/src/repl/panel/SoundsTab.jsx
Normal file
89
website/src/repl/panel/SoundsTab.jsx
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { useEvent } from '@strudel.cycles/react';
|
||||||
|
// import { cx } from '@strudel.cycles/react';
|
||||||
|
import { useStore } from '@nanostores/react';
|
||||||
|
import { getAudioContext, soundMap, connectToDestination } from '@strudel.cycles/webaudio';
|
||||||
|
import React, { useMemo, useRef } from 'react';
|
||||||
|
import { settingsMap, useSettings } from '../../settings.mjs';
|
||||||
|
import { ButtonGroup } from './Forms.jsx';
|
||||||
|
|
||||||
|
const getSamples = (samples) =>
|
||||||
|
Array.isArray(samples) ? samples.length : typeof samples === 'object' ? Object.values(samples).length : 1;
|
||||||
|
|
||||||
|
export function SoundsTab() {
|
||||||
|
const sounds = useStore(soundMap);
|
||||||
|
const { soundsFilter } = useSettings();
|
||||||
|
const soundEntries = useMemo(() => {
|
||||||
|
let filtered = Object.entries(sounds).filter(([key]) => !key.startsWith('_'));
|
||||||
|
if (!sounds) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (soundsFilter === 'user') {
|
||||||
|
return filtered.filter(([key, { data }]) => !data.prebake);
|
||||||
|
}
|
||||||
|
if (soundsFilter === 'drums') {
|
||||||
|
return filtered.filter(([_, { data }]) => data.type === 'sample' && data.tag === 'drum-machines');
|
||||||
|
}
|
||||||
|
if (soundsFilter === 'samples') {
|
||||||
|
return filtered.filter(([_, { data }]) => data.type === 'sample' && data.tag !== 'drum-machines');
|
||||||
|
}
|
||||||
|
if (soundsFilter === 'synths') {
|
||||||
|
return filtered.filter(([_, { data }]) => ['synth', 'soundfont'].includes(data.type));
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
}, [sounds, soundsFilter]);
|
||||||
|
// holds mutable ref to current triggered sound
|
||||||
|
const trigRef = useRef();
|
||||||
|
// stop current sound on mouseup
|
||||||
|
useEvent('mouseup', () => {
|
||||||
|
const t = trigRef.current;
|
||||||
|
trigRef.current = undefined;
|
||||||
|
t?.then((ref) => {
|
||||||
|
ref?.stop(getAudioContext().currentTime + 0.01);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div id="sounds-tab" className="flex flex-col w-full h-full dark:text-white text-stone-900">
|
||||||
|
<div className="px-2 pb-2 flex-none">
|
||||||
|
<ButtonGroup
|
||||||
|
value={soundsFilter}
|
||||||
|
onChange={(value) => settingsMap.setKey('soundsFilter', value)}
|
||||||
|
items={{
|
||||||
|
samples: 'samples',
|
||||||
|
drums: 'drum-machines',
|
||||||
|
synths: 'Synths',
|
||||||
|
user: 'User',
|
||||||
|
}}
|
||||||
|
></ButtonGroup>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 min-h-0 max-h-full grow overflow-auto font-mono text-sm break-normal">
|
||||||
|
{soundEntries.map(([name, { data, onTrigger }]) => (
|
||||||
|
<span
|
||||||
|
key={name}
|
||||||
|
className="cursor-pointer hover:opacity-50"
|
||||||
|
onMouseDown={async () => {
|
||||||
|
const ctx = getAudioContext();
|
||||||
|
const params = {
|
||||||
|
note: ['synth', 'soundfont'].includes(data.type) ? 'a3' : undefined,
|
||||||
|
s: name,
|
||||||
|
clip: 1,
|
||||||
|
release: 0.5,
|
||||||
|
};
|
||||||
|
const time = ctx.currentTime + 0.05;
|
||||||
|
const onended = () => trigRef.current?.node?.disconnect();
|
||||||
|
trigRef.current = Promise.resolve(onTrigger(time, params, onended));
|
||||||
|
trigRef.current.then((ref) => {
|
||||||
|
connectToDestination(ref?.node);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{' '}
|
||||||
|
{name}
|
||||||
|
{data?.type === 'sample' ? `(${getSamples(data.samples)})` : ''}
|
||||||
|
{data?.type === 'soundfont' ? `(${data.fonts.length})` : ''}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{!soundEntries.length ? 'No custom sounds loaded in this pattern (yet).' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
website/src/repl/panel/WelcomeTab.jsx
Normal file
53
website/src/repl/panel/WelcomeTab.jsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { cx } from '@strudel.cycles/react';
|
||||||
|
import React from 'react';
|
||||||
|
import * as tunes from '../tunes.mjs';
|
||||||
|
|
||||||
|
const { BASE_URL } = import.meta.env;
|
||||||
|
const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
|
||||||
|
|
||||||
|
export function WelcomeTab({ context }) {
|
||||||
|
return (
|
||||||
|
<div className="prose dark:prose-invert max-w-[600px] pt-2 font-sans pb-8 px-4">
|
||||||
|
<h3>
|
||||||
|
<span className={cx('animate-spin inline-block select-none')}>🌀</span> welcome
|
||||||
|
</h3>
|
||||||
|
<p>
|
||||||
|
You have found <span className="underline">strudel</span>, 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:
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<span className="underline">1. hit play</span> - <span className="underline">2. change something</span> -{' '}
|
||||||
|
<span className="underline">3. hit update</span>
|
||||||
|
<br />
|
||||||
|
If you don't like what you hear, try <span className="underline">shuffle</span>!
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
To learn more about what this all means, check out the{' '}
|
||||||
|
<a href={`${baseNoTrailing}/workshop/getting-started/`} target="_blank">
|
||||||
|
interactive tutorial
|
||||||
|
</a>
|
||||||
|
. Also feel free to join the{' '}
|
||||||
|
<a href="https://discord.com/invite/HGEdXmRkzT" target="_blank">
|
||||||
|
tidalcycles discord channel
|
||||||
|
</a>{' '}
|
||||||
|
to ask any questions, give feedback or just say hello.
|
||||||
|
</p>
|
||||||
|
<h3>about</h3>
|
||||||
|
<p>
|
||||||
|
strudel is a JavaScript version of{' '}
|
||||||
|
<a href="https://tidalcycles.org/" target="_blank">
|
||||||
|
tidalcycles
|
||||||
|
</a>
|
||||||
|
, which is a popular live coding language for music, written in Haskell. You can find the source code at{' '}
|
||||||
|
<a href="https://github.com/tidalcycles/strudel" target="_blank">
|
||||||
|
github
|
||||||
|
</a>
|
||||||
|
. Please consider to{' '}
|
||||||
|
<a href="https://opencollective.com/tidalcycles" target="_blank">
|
||||||
|
support this project
|
||||||
|
</a>{' '}
|
||||||
|
to ensure ongoing development 💖
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { persistentMap } from '@nanostores/persistent';
|
import { persistentMap } from '@nanostores/persistent';
|
||||||
import { useStore } from '@nanostores/react';
|
import { useStore } from '@nanostores/react';
|
||||||
import { register } from '@strudel.cycles/core';
|
import { register } from '@strudel.cycles/core';
|
||||||
|
import * as tunes from './repl/tunes.mjs';
|
||||||
|
|
||||||
export const defaultSettings = {
|
export const defaultSettings = {
|
||||||
activeFooter: 'intro',
|
activeFooter: 'intro',
|
||||||
|
|
@ -17,6 +18,8 @@ export const defaultSettings = {
|
||||||
isZen: false,
|
isZen: false,
|
||||||
soundsFilter: 'all',
|
soundsFilter: 'all',
|
||||||
panelPosition: 'bottom',
|
panelPosition: 'bottom',
|
||||||
|
userPatterns: '{}',
|
||||||
|
activePattern: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const settingsMap = persistentMap('strudel-settings', defaultSettings);
|
export const settingsMap = persistentMap('strudel-settings', defaultSettings);
|
||||||
|
|
@ -33,6 +36,7 @@ export function useSettings() {
|
||||||
isLineWrappingEnabled: [true, 'true'].includes(state.isLineWrappingEnabled) ? true : false,
|
isLineWrappingEnabled: [true, 'true'].includes(state.isLineWrappingEnabled) ? true : false,
|
||||||
fontSize: Number(state.fontSize),
|
fontSize: Number(state.fontSize),
|
||||||
panelPosition: state.activeFooter !== '' ? state.panelPosition : 'bottom',
|
panelPosition: state.activeFooter !== '' ? state.panelPosition : 'bottom',
|
||||||
|
userPatterns: JSON.parse(state.userPatterns),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,3 +61,131 @@ export const fontFamily = patternSetting('fontFamily');
|
||||||
export const fontSize = patternSetting('fontSize');
|
export const fontSize = patternSetting('fontSize');
|
||||||
|
|
||||||
export const settingPatterns = { theme, fontFamily, fontSize };
|
export const settingPatterns = { theme, fontFamily, fontSize };
|
||||||
|
|
||||||
|
function getUserPatterns() {
|
||||||
|
return JSON.parse(settingsMap.get().userPatterns);
|
||||||
|
}
|
||||||
|
function getSetting(key) {
|
||||||
|
return settingsMap.get()[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUserPatterns(obj) {
|
||||||
|
settingsMap.setKey('userPatterns', JSON.stringify(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addUserPattern(name, config) {
|
||||||
|
if (typeof config !== 'object') {
|
||||||
|
throw new Error('addUserPattern expected object as second param');
|
||||||
|
}
|
||||||
|
if (!config.code) {
|
||||||
|
throw new Error('addUserPattern expected code as property of second param');
|
||||||
|
}
|
||||||
|
const userPatterns = getUserPatterns();
|
||||||
|
setUserPatterns({ ...userPatterns, [name]: config });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newUserPattern() {
|
||||||
|
const userPatterns = getUserPatterns();
|
||||||
|
const date = new Date().toISOString().split('T')[0];
|
||||||
|
const todays = Object.entries(userPatterns).filter(([name]) => name.startsWith(date));
|
||||||
|
const num = String(todays.length + 1).padStart(3, '0');
|
||||||
|
const defaultNewPattern = 's("hh")';
|
||||||
|
const name = date + '_' + num;
|
||||||
|
addUserPattern(name, { code: defaultNewPattern });
|
||||||
|
setActivePattern(name);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearUserPatterns() {
|
||||||
|
if (!confirm(`This will delete all your patterns. Are you really sure?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUserPatterns({});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNextCloneName(key) {
|
||||||
|
const userPatterns = getUserPatterns();
|
||||||
|
const clones = Object.entries(userPatterns).filter(([name]) => name.startsWith(key));
|
||||||
|
const num = String(clones.length + 1).padStart(3, '0');
|
||||||
|
return key + '_' + num;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserPattern(key) {
|
||||||
|
const userPatterns = getUserPatterns();
|
||||||
|
return userPatterns[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renameActivePattern() {
|
||||||
|
let activePattern = getSetting('activePattern');
|
||||||
|
let userPatterns = getUserPatterns();
|
||||||
|
if (!userPatterns[activePattern]) {
|
||||||
|
alert('Cannot rename examples');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newName = prompt('Enter new name', activePattern);
|
||||||
|
if (userPatterns[newName]) {
|
||||||
|
alert('Name already taken!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
userPatterns[newName] = userPatterns[activePattern]; // copy code
|
||||||
|
delete userPatterns[activePattern];
|
||||||
|
setUserPatterns({ ...userPatterns });
|
||||||
|
setActivePattern(newName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateUserCode(code) {
|
||||||
|
const userPatterns = getUserPatterns();
|
||||||
|
let activePattern = getSetting('activePattern');
|
||||||
|
// check if code is that of an example tune
|
||||||
|
const [example] = Object.entries(tunes).find(([_, tune]) => tune === code) || [];
|
||||||
|
if (example) {
|
||||||
|
// select example
|
||||||
|
setActivePattern(example);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!activePattern) {
|
||||||
|
// create new user pattern
|
||||||
|
activePattern = newUserPattern();
|
||||||
|
setActivePattern(activePattern);
|
||||||
|
} else if (!!tunes[activePattern] && code !== tunes[activePattern]) {
|
||||||
|
// fork example
|
||||||
|
activePattern = getNextCloneName(activePattern);
|
||||||
|
setActivePattern(activePattern);
|
||||||
|
}
|
||||||
|
setUserPatterns({ ...userPatterns, [activePattern]: { code } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteActivePattern() {
|
||||||
|
let activePattern = getSetting('activePattern');
|
||||||
|
if (!activePattern) {
|
||||||
|
console.warn('cannot delete: no pattern selected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userPatterns = getUserPatterns();
|
||||||
|
if (!userPatterns[activePattern]) {
|
||||||
|
alert('Cannot delete examples');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirm(`Really delete the selected pattern "${activePattern}"?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUserPatterns(Object.fromEntries(Object.entries(userPatterns).filter(([key]) => key !== activePattern)));
|
||||||
|
setActivePattern('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function duplicateActivePattern() {
|
||||||
|
let activePattern = getSetting('activePattern');
|
||||||
|
let latestCode = getSetting('latestCode');
|
||||||
|
if (!activePattern) {
|
||||||
|
console.warn('cannot duplicate: no pattern selected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userPatterns = getUserPatterns();
|
||||||
|
activePattern = getNextCloneName(activePattern);
|
||||||
|
setUserPatterns({ ...userPatterns, [activePattern]: { code: latestCode } });
|
||||||
|
setActivePattern(activePattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setActivePattern(key) {
|
||||||
|
settingsMap.setKey('activePattern', key);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue