commit
9be1f9c18b
6 changed files with 188 additions and 9 deletions
|
|
@ -1,3 +1,4 @@
|
||||||
export * from './codemirror.mjs';
|
export * from './codemirror.mjs';
|
||||||
export * from './highlight.mjs';
|
export * from './highlight.mjs';
|
||||||
export * from './flash.mjs';
|
export * from './flash.mjs';
|
||||||
|
export * from './slider.mjs';
|
||||||
|
|
|
||||||
132
packages/codemirror/slider.mjs
Normal file
132
packages/codemirror/slider.mjs
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { ref, pure } from '@strudel.cycles/core';
|
||||||
|
import { WidgetType, ViewPlugin, Decoration } from '@codemirror/view';
|
||||||
|
import { StateEffect, StateField } from '@codemirror/state';
|
||||||
|
|
||||||
|
export let sliderValues = {};
|
||||||
|
const getSliderID = (from) => `slider_${from}`;
|
||||||
|
|
||||||
|
export class SliderWidget extends WidgetType {
|
||||||
|
constructor(value, min, max, from, to, view) {
|
||||||
|
super();
|
||||||
|
this.value = value;
|
||||||
|
this.min = min;
|
||||||
|
this.max = max;
|
||||||
|
this.from = from;
|
||||||
|
this.originalFrom = from;
|
||||||
|
this.to = to;
|
||||||
|
this.view = view;
|
||||||
|
}
|
||||||
|
|
||||||
|
eq() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM() {
|
||||||
|
let wrap = document.createElement('span');
|
||||||
|
wrap.setAttribute('aria-hidden', 'true');
|
||||||
|
wrap.className = 'cm-slider'; // inline-flex items-center
|
||||||
|
let slider = wrap.appendChild(document.createElement('input'));
|
||||||
|
slider.type = 'range';
|
||||||
|
slider.min = this.min;
|
||||||
|
slider.max = this.max;
|
||||||
|
slider.step = (this.max - this.min) / 1000;
|
||||||
|
slider.originalValue = this.value;
|
||||||
|
// to make sure the code stays in sync, let's save the original value
|
||||||
|
// becuase .value automatically clamps values so it'll desync with the code
|
||||||
|
slider.value = slider.originalValue;
|
||||||
|
slider.from = this.from;
|
||||||
|
slider.originalFrom = this.originalFrom;
|
||||||
|
slider.to = this.to;
|
||||||
|
slider.style = 'width:64px;margin-right:4px;transform:translateY(4px)';
|
||||||
|
this.slider = slider;
|
||||||
|
slider.addEventListener('input', (e) => {
|
||||||
|
const next = e.target.value;
|
||||||
|
let insert = next;
|
||||||
|
//let insert = next.toFixed(2);
|
||||||
|
const to = slider.from + slider.originalValue.length;
|
||||||
|
let change = { from: slider.from, to, insert };
|
||||||
|
slider.originalValue = insert;
|
||||||
|
slider.value = insert;
|
||||||
|
this.view.dispatch({ changes: change });
|
||||||
|
const id = getSliderID(slider.originalFrom); // matches id generated in transpiler
|
||||||
|
window.postMessage({ type: 'cm-slider', value: Number(next), id });
|
||||||
|
});
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreEvent(e) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setWidgets = StateEffect.define();
|
||||||
|
|
||||||
|
export const updateWidgets = (view, widgets) => {
|
||||||
|
view.dispatch({ effects: setWidgets.of(widgets) });
|
||||||
|
};
|
||||||
|
|
||||||
|
function getWidgets(widgetConfigs, view) {
|
||||||
|
return widgetConfigs.map(({ from, to, value, min, max }) => {
|
||||||
|
return Decoration.widget({
|
||||||
|
widget: new SliderWidget(value, min, max, from, to, view),
|
||||||
|
side: 0,
|
||||||
|
}).range(from /* , to */);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sliderPlugin = ViewPlugin.fromClass(
|
||||||
|
class {
|
||||||
|
decorations; //: DecorationSet
|
||||||
|
|
||||||
|
constructor(view /* : EditorView */) {
|
||||||
|
this.decorations = Decoration.set([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update /* : ViewUpdate */) {
|
||||||
|
update.transactions.forEach((tr) => {
|
||||||
|
if (tr.docChanged) {
|
||||||
|
this.decorations = this.decorations.map(tr.changes);
|
||||||
|
const iterator = this.decorations.iter();
|
||||||
|
while (iterator.value) {
|
||||||
|
// when the widgets are moved, we need to tell the dom node the current position
|
||||||
|
// this is important because the updateSliderValue function has to work with the dom node
|
||||||
|
iterator.value.widget.slider.from = iterator.from;
|
||||||
|
iterator.value.widget.slider.to = iterator.to;
|
||||||
|
iterator.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let e of tr.effects) {
|
||||||
|
if (e.is(setWidgets)) {
|
||||||
|
this.decorations = Decoration.set(getWidgets(e.value, update.view));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decorations: (v) => v.decorations,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export let slider = (value) => {
|
||||||
|
console.warn('slider will only work when the transpiler is used... passing value as is');
|
||||||
|
return pure(value);
|
||||||
|
};
|
||||||
|
// function transpiled from slider = (value, min, max)
|
||||||
|
export let sliderWithID = (id, value, min, max) => {
|
||||||
|
sliderValues[id] = value; // sync state at eval time (code -> state)
|
||||||
|
return ref(() => sliderValues[id]); // use state at query time
|
||||||
|
};
|
||||||
|
// update state when sliders are moved
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('message', (e) => {
|
||||||
|
if (e.data.type === 'cm-slider') {
|
||||||
|
if (sliderValues[e.data.id] !== undefined) {
|
||||||
|
// update state when slider is moved
|
||||||
|
sliderValues[e.data.id] = e.data.value;
|
||||||
|
} else {
|
||||||
|
console.warn(`slider with id "${e.data.id}" is not registered. Only ${Object.keys(sliderValues)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -15,10 +15,11 @@ import {
|
||||||
updateMiniLocations,
|
updateMiniLocations,
|
||||||
} from '@strudel/codemirror';
|
} from '@strudel/codemirror';
|
||||||
import './style.css';
|
import './style.css';
|
||||||
|
import { sliderPlugin } from '@strudel/codemirror/slider.mjs';
|
||||||
|
|
||||||
export { flash, highlightMiniLocations, updateMiniLocations };
|
export { flash, highlightMiniLocations, updateMiniLocations };
|
||||||
|
|
||||||
const staticExtensions = [javascript(), flashField, highlightExtension];
|
const staticExtensions = [javascript(), flashField, highlightExtension, sliderPlugin];
|
||||||
|
|
||||||
export default function CodeMirror({
|
export default function CodeMirror({
|
||||||
value,
|
value,
|
||||||
|
|
|
||||||
13
packages/react/src/hooks/useWidgets.mjs
Normal file
13
packages/react/src/hooks/useWidgets.mjs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { updateWidgets } from '@strudel/codemirror';
|
||||||
|
|
||||||
|
// i know this is ugly.. in the future, repl needs to run without react
|
||||||
|
export function useWidgets(view) {
|
||||||
|
const [widgets, setWidgets] = useState([]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (view) {
|
||||||
|
updateWidgets(view, widgets);
|
||||||
|
}
|
||||||
|
}, [view, widgets]);
|
||||||
|
return { widgets, setWidgets };
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ import { isNoteWithOctave } from '@strudel.cycles/core';
|
||||||
import { getLeafLocations } from '@strudel.cycles/mini';
|
import { getLeafLocations } from '@strudel.cycles/mini';
|
||||||
|
|
||||||
export function transpiler(input, options = {}) {
|
export function transpiler(input, options = {}) {
|
||||||
const { wrapAsync = false, addReturn = true, emitMiniLocations = true } = options;
|
const { wrapAsync = false, addReturn = true, emitMiniLocations = true, emitWidgets = true } = options;
|
||||||
|
|
||||||
let ast = parse(input, {
|
let ast = parse(input, {
|
||||||
ecmaVersion: 2022,
|
ecmaVersion: 2022,
|
||||||
|
|
@ -16,9 +16,9 @@ export function transpiler(input, options = {}) {
|
||||||
let miniLocations = [];
|
let miniLocations = [];
|
||||||
const collectMiniLocations = (value, node) => {
|
const collectMiniLocations = (value, node) => {
|
||||||
const leafLocs = getLeafLocations(`"${value}"`, node.start); // stimmt!
|
const leafLocs = getLeafLocations(`"${value}"`, node.start); // stimmt!
|
||||||
//const withOffset = leafLocs.map((offsets) => offsets.map((o) => o + node.start));
|
|
||||||
miniLocations = miniLocations.concat(leafLocs);
|
miniLocations = miniLocations.concat(leafLocs);
|
||||||
};
|
};
|
||||||
|
let widgets = [];
|
||||||
|
|
||||||
walk(ast, {
|
walk(ast, {
|
||||||
enter(node, parent /* , prop, index */) {
|
enter(node, parent /* , prop, index */) {
|
||||||
|
|
@ -35,6 +35,17 @@ export function transpiler(input, options = {}) {
|
||||||
emitMiniLocations && collectMiniLocations(value, node);
|
emitMiniLocations && collectMiniLocations(value, node);
|
||||||
return this.replace(miniWithLocation(value, node));
|
return this.replace(miniWithLocation(value, node));
|
||||||
}
|
}
|
||||||
|
if (isWidgetFunction(node)) {
|
||||||
|
emitWidgets &&
|
||||||
|
widgets.push({
|
||||||
|
from: node.arguments[0].start,
|
||||||
|
to: node.arguments[0].end,
|
||||||
|
value: node.arguments[0].raw, // don't use value!
|
||||||
|
min: node.arguments[1]?.value ?? 0,
|
||||||
|
max: node.arguments[2]?.value ?? 1,
|
||||||
|
});
|
||||||
|
return this.replace(widgetWithLocation(node));
|
||||||
|
}
|
||||||
// TODO: remove pseudo note variables?
|
// TODO: remove pseudo note variables?
|
||||||
if (node.type === 'Identifier' && isNoteWithOctave(node.name)) {
|
if (node.type === 'Identifier' && isNoteWithOctave(node.name)) {
|
||||||
this.skip();
|
this.skip();
|
||||||
|
|
@ -64,15 +75,14 @@ export function transpiler(input, options = {}) {
|
||||||
if (!emitMiniLocations) {
|
if (!emitMiniLocations) {
|
||||||
return { output };
|
return { output };
|
||||||
}
|
}
|
||||||
return { output, miniLocations };
|
return { output, miniLocations, widgets };
|
||||||
}
|
}
|
||||||
|
|
||||||
function isStringWithDoubleQuotes(node, locations, code) {
|
function isStringWithDoubleQuotes(node, locations, code) {
|
||||||
const { raw, type } = node;
|
if (node.type !== 'Literal') {
|
||||||
if (type !== 'Literal') {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return raw[0] === '"';
|
return node.raw[0] === '"';
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBackTickString(node, parent) {
|
function isBackTickString(node, parent) {
|
||||||
|
|
@ -94,3 +104,22 @@ function miniWithLocation(value, node) {
|
||||||
optional: false,
|
optional: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// these functions are connected to @strudel/codemirror -> slider.mjs
|
||||||
|
// maybe someday there will be pluggable transpiler functions, then move this there
|
||||||
|
function isWidgetFunction(node) {
|
||||||
|
return node.type === 'CallExpression' && node.callee.name === 'slider';
|
||||||
|
}
|
||||||
|
|
||||||
|
function widgetWithLocation(node) {
|
||||||
|
const id = 'slider_' + node.arguments[0].start; // use loc of first arg for id
|
||||||
|
// add loc as identifier to first argument
|
||||||
|
// the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?)
|
||||||
|
node.arguments.unshift({
|
||||||
|
type: 'Literal',
|
||||||
|
value: id,
|
||||||
|
raw: id,
|
||||||
|
});
|
||||||
|
node.callee.name = 'sliderWithID';
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ 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';
|
||||||
import { isTauri } from '../tauri.mjs';
|
import { isTauri } from '../tauri.mjs';
|
||||||
|
import { useWidgets } from '@strudel.cycles/react/src/hooks/useWidgets.mjs';
|
||||||
|
|
||||||
const { latestCode } = settingsMap.get();
|
const { latestCode } = settingsMap.get();
|
||||||
|
|
||||||
|
|
@ -39,6 +40,7 @@ let modules = [
|
||||||
import('@strudel.cycles/mini'),
|
import('@strudel.cycles/mini'),
|
||||||
import('@strudel.cycles/xen'),
|
import('@strudel.cycles/xen'),
|
||||||
import('@strudel.cycles/webaudio'),
|
import('@strudel.cycles/webaudio'),
|
||||||
|
import('@strudel/codemirror'),
|
||||||
|
|
||||||
import('@strudel.cycles/serial'),
|
import('@strudel.cycles/serial'),
|
||||||
import('@strudel.cycles/soundfonts'),
|
import('@strudel.cycles/soundfonts'),
|
||||||
|
|
@ -128,7 +130,7 @@ export function Repl({ embedded = false }) {
|
||||||
} = useSettings();
|
} = useSettings();
|
||||||
|
|
||||||
const paintOptions = useMemo(() => ({ fontFamily }), [fontFamily]);
|
const paintOptions = useMemo(() => ({ fontFamily }), [fontFamily]);
|
||||||
|
const { setWidgets } = useWidgets(view);
|
||||||
const { code, setCode, scheduler, evaluate, activateCode, isDirty, activeCode, pattern, started, stop, error } =
|
const { code, setCode, scheduler, evaluate, activateCode, isDirty, activeCode, pattern, started, stop, error } =
|
||||||
useStrudel({
|
useStrudel({
|
||||||
initialCode: '// LOADING...',
|
initialCode: '// LOADING...',
|
||||||
|
|
@ -142,6 +144,7 @@ export function Repl({ embedded = false }) {
|
||||||
},
|
},
|
||||||
afterEval: ({ code, meta }) => {
|
afterEval: ({ code, meta }) => {
|
||||||
setMiniLocations(meta.miniLocations);
|
setMiniLocations(meta.miniLocations);
|
||||||
|
setWidgets(meta.widgets);
|
||||||
setPending(false);
|
setPending(false);
|
||||||
setLatestCode(code);
|
setLatestCode(code);
|
||||||
window.location.hash = '#' + code2hash(code);
|
window.location.hash = '#' + code2hash(code);
|
||||||
|
|
@ -219,7 +222,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],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue