Merge pull request 'block based eval' (#1714) from Dsm0/strudel:block-based-eval-squashed into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1714
This commit is contained in:
commit
70be560a7a
15 changed files with 1202 additions and 223 deletions
51
packages/codemirror/block_utilities.mjs
Normal file
51
packages/codemirror/block_utilities.mjs
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
// Block-based evaluation utilities
|
||||||
|
|
||||||
|
export function getBlockRegions(code) {
|
||||||
|
const chars = code.split('');
|
||||||
|
let i = 0,
|
||||||
|
blanks = [],
|
||||||
|
blockStart = 0,
|
||||||
|
regions = [];
|
||||||
|
while (i < chars.length) {
|
||||||
|
const isBlank = chars[i] === '\n';
|
||||||
|
if (isBlank) {
|
||||||
|
blanks.push(i);
|
||||||
|
} else if (chars[i].trim() !== '') {
|
||||||
|
if (blanks.length > 1) {
|
||||||
|
regions.push([blockStart, blanks[0]]);
|
||||||
|
blockStart = i;
|
||||||
|
}
|
||||||
|
blanks = [];
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
regions.push([blockStart, blanks.length ? blanks[0] : i]);
|
||||||
|
return regions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlockAt(code, cursor) {
|
||||||
|
const regions = getBlockRegions(code);
|
||||||
|
for (const [start, end] of regions) {
|
||||||
|
if (cursor >= start && cursor <= end) {
|
||||||
|
return [start, end];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const evalBlock = (strudelMirror) => {
|
||||||
|
const { state } = strudelMirror.editor;
|
||||||
|
const code = state.doc.toString();
|
||||||
|
const cursor = state.selection.main.head;
|
||||||
|
const range = getBlockAt(code, cursor);
|
||||||
|
if (range) {
|
||||||
|
const [a, b] = range;
|
||||||
|
const block = code.slice(a, b);
|
||||||
|
if (block) {
|
||||||
|
// Flash the block being evaluated
|
||||||
|
strudelMirror.flash(200, { from: a, to: b });
|
||||||
|
strudelMirror.repl.evaluateBlock(block, true, { range });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
@ -13,18 +13,18 @@ import {
|
||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import { persistentAtom } from '@nanostores/persistent';
|
import { persistentAtom } from '@nanostores/persistent';
|
||||||
import { logger, registerControl, repl } from '@strudel/core';
|
import { logger, registerControl, repl } from '@strudel/core';
|
||||||
import { cleanupDraw, Drawer } from '@strudel/draw';
|
import { cleanupDraw, cleanupDrawContext, Drawer } from '@strudel/draw';
|
||||||
|
|
||||||
import { isAutoCompletionEnabled } from './autocomplete.mjs';
|
import { isAutoCompletionEnabled } from './autocomplete.mjs';
|
||||||
import { basicSetup } from './basicSetup.mjs';
|
import { basicSetup } from './basicSetup.mjs';
|
||||||
|
import { evalBlock } from './block_utilities.mjs';
|
||||||
import { flash, isFlashEnabled } from './flash.mjs';
|
import { flash, isFlashEnabled } from './flash.mjs';
|
||||||
import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs';
|
import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs';
|
||||||
import { keybindings } from './keybindings.mjs';
|
import { keybindings } from './keybindings.mjs';
|
||||||
import { sliderPlugin, updateSliderWidgets } from './slider.mjs';
|
import { jumpToCharacter } from './labelJump.mjs';
|
||||||
|
import { getSliderWidgets, sliderPlugin, updateSliderWidgets } from './slider.mjs';
|
||||||
import { activateTheme, initTheme, theme } from './themes.mjs';
|
import { activateTheme, initTheme, theme } from './themes.mjs';
|
||||||
import { isTooltipEnabled } from './tooltip.mjs';
|
import { isTooltipEnabled } from './tooltip.mjs';
|
||||||
import { updateWidgets, widgetPlugin } from './widget.mjs';
|
import { getActiveWidgets, updateWidgets, widgetPlugin } from './widget.mjs';
|
||||||
import { jumpToCharacter } from './labelJump.mjs';
|
|
||||||
|
|
||||||
export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands';
|
export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands';
|
||||||
|
|
||||||
|
|
@ -64,6 +64,7 @@ export const defaultSettings = {
|
||||||
isLineWrappingEnabled: false,
|
isLineWrappingEnabled: false,
|
||||||
isTabIndentationEnabled: false,
|
isTabIndentationEnabled: false,
|
||||||
isMultiCursorEnabled: false,
|
isMultiCursorEnabled: false,
|
||||||
|
isBlockBasedEvalEnabled: false,
|
||||||
theme: 'strudelTheme',
|
theme: 'strudelTheme',
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
|
|
@ -75,7 +76,7 @@ export const codemirrorSettings = persistentAtom('codemirror-settings', defaultS
|
||||||
});
|
});
|
||||||
|
|
||||||
// https://codemirror.net/docs/guide/
|
// https://codemirror.net/docs/guide/
|
||||||
export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, root, mondo }) {
|
export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, root, mondo, strudelMirror }) {
|
||||||
const settings = codemirrorSettings.get();
|
const settings = codemirrorSettings.get();
|
||||||
const initialSettings = Object.keys(compartments).map((key) =>
|
const initialSettings = Object.keys(compartments).map((key) =>
|
||||||
compartments[key].of(extensions[key](parseBooleans(settings[key]))),
|
compartments[key].of(extensions[key](parseBooleans(settings[key]))),
|
||||||
|
|
@ -105,11 +106,26 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
|
||||||
keymap.of([
|
keymap.of([
|
||||||
{
|
{
|
||||||
key: 'Ctrl-Enter',
|
key: 'Ctrl-Enter',
|
||||||
run: () => onEvaluate?.(),
|
run: () => {
|
||||||
|
// issue with referencing settings, this works more reliably
|
||||||
|
if (strudelMirror?.isBlockBasedEvalEnabled) {
|
||||||
|
evalBlock(strudelMirror);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return onEvaluate?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'Alt-Enter',
|
key: 'Alt-Enter',
|
||||||
run: () => onEvaluate?.(),
|
run: () => {
|
||||||
|
if (strudelMirror?.isBlockBasedEvalEnabled) {
|
||||||
|
evalBlock(strudelMirror);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return onEvaluate?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'Ctrl-.',
|
key: 'Ctrl-.',
|
||||||
|
|
@ -171,6 +187,7 @@ export class StrudelMirror {
|
||||||
this.onDraw = onDraw || this.draw;
|
this.onDraw = onDraw || this.draw;
|
||||||
this.id = id || s4();
|
this.id = id || s4();
|
||||||
this.solo = solo;
|
this.solo = solo;
|
||||||
|
this.isBlockBasedEvalEnabled = false; // Will be updated via updateSettings()
|
||||||
|
|
||||||
this.drawer = new Drawer((haps, time, _, painters) => {
|
this.drawer = new Drawer((haps, time, _, painters) => {
|
||||||
const currentFrame = haps.filter((hap) => hap.isActive(time));
|
const currentFrame = haps.filter((hap) => hap.isActive(time));
|
||||||
|
|
@ -201,20 +218,28 @@ export class StrudelMirror {
|
||||||
cleanupDraw(true, id);
|
cleanupDraw(true, id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
beforeEval: async () => {
|
beforeEval: async ({ blockBased } = {}) => {
|
||||||
|
// Only clean up all drawings for full evaluation
|
||||||
|
// Block-based eval should preserve animations (like .scope()) from other blocks
|
||||||
|
if (!blockBased) {
|
||||||
cleanupDraw(true, id);
|
cleanupDraw(true, id);
|
||||||
|
}
|
||||||
await this.prebaked;
|
await this.prebaked;
|
||||||
await replOptions?.beforeEval?.();
|
await replOptions?.beforeEval?.();
|
||||||
},
|
},
|
||||||
afterEval: (options) => {
|
afterEval: (options) => {
|
||||||
// remember for when highlighting is toggled on
|
// remember for when highlighting is toggled on
|
||||||
this.miniLocations = options.meta?.miniLocations;
|
this.miniLocations = options.meta?.miniLocations || [];
|
||||||
this.widgets = options.meta?.widgets;
|
this.widgets = options.meta?.widgets;
|
||||||
|
|
||||||
const sliders = this.widgets.filter((w) => w.type === 'slider');
|
const sliders = this.widgets.filter((w) => w.type === 'slider');
|
||||||
updateSliderWidgets(this.editor, sliders);
|
|
||||||
const widgets = this.widgets.filter((w) => w.type !== 'slider');
|
const widgets = this.widgets.filter((w) => w.type !== 'slider');
|
||||||
updateWidgets(this.editor, widgets);
|
// range-aware update for block-based evaluation
|
||||||
updateMiniLocations(this.editor, this.miniLocations);
|
const range = options.range && options.range.length >= 2 ? options.range : null;
|
||||||
|
|
||||||
|
updateSliderWidgets(this.editor, sliders, range);
|
||||||
|
updateWidgets(this.editor, widgets, range);
|
||||||
|
updateMiniLocations(this.editor, this.miniLocations, range);
|
||||||
replOptions?.afterEval?.(options);
|
replOptions?.afterEval?.(options);
|
||||||
// if no painters are set (.onPaint was not called), then we only need
|
// if no painters are set (.onPaint was not called), then we only need
|
||||||
// the present moment (for highlighting)
|
// the present moment (for highlighting)
|
||||||
|
|
@ -222,8 +247,14 @@ export class StrudelMirror {
|
||||||
this.drawer.setDrawTime(drawTime);
|
this.drawer.setDrawTime(drawTime);
|
||||||
// invalidate drawer after we've set the appropriate drawTime
|
// invalidate drawer after we've set the appropriate drawTime
|
||||||
this.drawer.invalidate(this.repl.scheduler);
|
this.drawer.invalidate(this.repl.scheduler);
|
||||||
|
|
||||||
|
// Clean up draw context if a non-inline widget was removed
|
||||||
|
if (options.widgetRemoved) {
|
||||||
|
cleanupDrawContext(id);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
this.cleanupDrawContext = () => cleanupDrawContext(id);
|
||||||
this.editor = initEditor({
|
this.editor = initEditor({
|
||||||
root,
|
root,
|
||||||
initialCode,
|
initialCode,
|
||||||
|
|
@ -236,7 +267,9 @@ export class StrudelMirror {
|
||||||
onEvaluate: () => this.evaluate(),
|
onEvaluate: () => this.evaluate(),
|
||||||
onStop: () => this.stop(),
|
onStop: () => this.stop(),
|
||||||
mondo: replOptions.mondo,
|
mondo: replOptions.mondo,
|
||||||
|
strudelMirror: this,
|
||||||
});
|
});
|
||||||
|
|
||||||
const cmEditor = this.root.querySelector('.cm-editor');
|
const cmEditor = this.root.querySelector('.cm-editor');
|
||||||
if (cmEditor) {
|
if (cmEditor) {
|
||||||
this.root.style.display = 'block';
|
this.root.style.display = 'block';
|
||||||
|
|
@ -306,8 +339,9 @@ export class StrudelMirror {
|
||||||
this.flash();
|
this.flash();
|
||||||
await this.repl.evaluate(this.code, autostart);
|
await this.repl.evaluate(this.code, autostart);
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop() {
|
async stop() {
|
||||||
this.repl.scheduler.stop();
|
this.repl.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for global stop requests (e.g., from Vim :q)
|
// Listen for global stop requests (e.g., from Vim :q)
|
||||||
|
|
@ -326,8 +360,8 @@ export class StrudelMirror {
|
||||||
this.evaluate();
|
this.evaluate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
flash(ms) {
|
flash(ms, range) {
|
||||||
flash(this.editor, ms);
|
flash(this.editor, ms, range);
|
||||||
}
|
}
|
||||||
highlight(haps, time) {
|
highlight(haps, time) {
|
||||||
highlightMiniLocations(this.editor, time, haps);
|
highlightMiniLocations(this.editor, time, haps);
|
||||||
|
|
@ -359,6 +393,10 @@ export class StrudelMirror {
|
||||||
setLineWrappingEnabled(enabled) {
|
setLineWrappingEnabled(enabled) {
|
||||||
this.reconfigureExtension('isLineWrappingEnabled', enabled);
|
this.reconfigureExtension('isLineWrappingEnabled', enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setBlockBasedEvalEnabled(enabled) {
|
||||||
|
this.reconfigureExtension('isBlockBasedEvalEnabled', enabled);
|
||||||
|
}
|
||||||
setBracketMatchingEnabled(enabled) {
|
setBracketMatchingEnabled(enabled) {
|
||||||
this.reconfigureExtension('isBracketMatchingEnabled', enabled);
|
this.reconfigureExtension('isBracketMatchingEnabled', enabled);
|
||||||
}
|
}
|
||||||
|
|
@ -380,6 +418,10 @@ export class StrudelMirror {
|
||||||
for (let key in extensions) {
|
for (let key in extensions) {
|
||||||
this.reconfigureExtension(key, settings[key]);
|
this.reconfigureExtension(key, settings[key]);
|
||||||
}
|
}
|
||||||
|
// Update block-based eval setting on the instance
|
||||||
|
if (settings.isBlockBasedEvalEnabled !== undefined) {
|
||||||
|
this.isBlockBasedEvalEnabled = parseBooleans(settings.isBlockBasedEvalEnabled);
|
||||||
|
}
|
||||||
const updated = { ...codemirrorSettings.get(), ...settings };
|
const updated = { ...codemirrorSettings.get(), ...settings };
|
||||||
codemirrorSettings.set(updated);
|
codemirrorSettings.set(updated);
|
||||||
}
|
}
|
||||||
|
|
@ -401,6 +443,16 @@ export class StrudelMirror {
|
||||||
};
|
};
|
||||||
this.editor.dispatch({ changes });
|
this.editor.dispatch({ changes });
|
||||||
}
|
}
|
||||||
|
// used for debugging but could serve other purposes
|
||||||
|
getActiveWidgets() {
|
||||||
|
return getActiveWidgets(this.editor);
|
||||||
|
}
|
||||||
|
getSliderWidgets() {
|
||||||
|
return getSliderWidgets(this.editor);
|
||||||
|
}
|
||||||
|
getMiniLocations() {
|
||||||
|
return this.miniLocations;
|
||||||
|
}
|
||||||
clear() {
|
clear() {
|
||||||
this.onStartRepl && document.removeEventListener('start-repl', this.onStartRepl);
|
this.onStartRepl && document.removeEventListener('start-repl', this.onStartRepl);
|
||||||
this.onEvaluateRequest && document.removeEventListener('repl-evaluate', this.onEvaluateRequest);
|
this.onEvaluateRequest && document.removeEventListener('repl-evaluate', this.onEvaluateRequest);
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@ export const flashField = StateField.define({
|
||||||
const mark = Decoration.mark({
|
const mark = Decoration.mark({
|
||||||
attributes: { style: `background-color: rgba(255,255,255, .4); filter: invert(10%)` },
|
attributes: { style: `background-color: rgba(255,255,255, .4); filter: invert(10%)` },
|
||||||
});
|
});
|
||||||
flash = Decoration.set([mark.range(0, tr.newDoc.length)]);
|
const range = e.value.range || { from: 0, to: tr.newDoc.length };
|
||||||
|
flash = Decoration.set([mark.range(range.from, range.to)]);
|
||||||
} else {
|
} else {
|
||||||
flash = Decoration.set([]);
|
flash = Decoration.set([]);
|
||||||
}
|
}
|
||||||
|
|
@ -29,8 +30,9 @@ export const flashField = StateField.define({
|
||||||
provide: (f) => EditorView.decorations.from(f),
|
provide: (f) => EditorView.decorations.from(f),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const flash = (view, ms = 200) => {
|
export const flash = (view, ms = 200, range) => {
|
||||||
view.dispatch({ effects: setFlash.of(true) });
|
const flashData = range ? { range } : true;
|
||||||
|
view.dispatch({ effects: setFlash.of(flashData) });
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
view.dispatch({ effects: setFlash.of(false) });
|
view.dispatch({ effects: setFlash.of(false) });
|
||||||
}, ms);
|
}, ms);
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ import { Decoration, EditorView } from '@codemirror/view';
|
||||||
|
|
||||||
export const setMiniLocations = StateEffect.define();
|
export const setMiniLocations = StateEffect.define();
|
||||||
export const showMiniLocations = StateEffect.define();
|
export const showMiniLocations = StateEffect.define();
|
||||||
export const updateMiniLocations = (view, locations) => {
|
export const displayMiniLocations = StateEffect.define();
|
||||||
view.dispatch({ effects: setMiniLocations.of(locations) });
|
export const updateMiniLocations = (view, locations, range = null) => {
|
||||||
|
view.dispatch({ effects: setMiniLocations.of({ locations, range }) });
|
||||||
};
|
};
|
||||||
export const highlightMiniLocations = (view, atTime, haps) => {
|
export const highlightMiniLocations = (view, atTime, haps) => {
|
||||||
view.dispatch({ effects: showMiniLocations.of({ atTime, haps }) });
|
view.dispatch({ effects: showMiniLocations.of({ atTime, haps }) });
|
||||||
|
|
@ -21,10 +22,41 @@ const miniLocations = StateField.define({
|
||||||
|
|
||||||
for (let e of tr.effects) {
|
for (let e of tr.effects) {
|
||||||
if (e.is(setMiniLocations)) {
|
if (e.is(setMiniLocations)) {
|
||||||
|
//block-based eval case
|
||||||
|
if (e.value.range) {
|
||||||
|
const stateMiniLocations = getMiniLocationsFromDecorations(locations);
|
||||||
|
|
||||||
|
const normalized = e.value.locations
|
||||||
|
.filter(([from]) => from < tr.newDoc.length)
|
||||||
|
.map(([from, to]) => [from, Math.min(to, tr.newDoc.length)]);
|
||||||
|
|
||||||
|
const newIds = new Set(normalized.map((r) => r.join(':')));
|
||||||
|
|
||||||
|
const marks = normalized.map((range) => {
|
||||||
|
const id = range.join(':');
|
||||||
|
return Decoration.mark({
|
||||||
|
id,
|
||||||
|
// this green is only to verify that the decoration moves when the document is edited
|
||||||
|
// it will be removed later, so the mark is not visible by default
|
||||||
|
attributes: { style: `background-color: #00CA2880` },
|
||||||
|
}).range(...range); // -> Decoration
|
||||||
|
});
|
||||||
|
|
||||||
|
const previousMarks = stateMiniLocations
|
||||||
|
.filter(({ id }) => !newIds.has(id))
|
||||||
|
.map(({ from, to, id }) =>
|
||||||
|
Decoration.mark({
|
||||||
|
id,
|
||||||
|
attributes: { style: `background-color: #00CA2880` },
|
||||||
|
}).range(from, to),
|
||||||
|
);
|
||||||
|
|
||||||
|
locations = Decoration.set(previousMarks.concat(marks), true); // -> DecorationSet === RangeSet<Decoration>
|
||||||
|
} else {
|
||||||
// this is called on eval, with the mini locations obtained from the transpiler
|
// this is called on eval, with the mini locations obtained from the transpiler
|
||||||
// codemirror will automatically remap the marks when the document is edited
|
// codemirror will automatically remap the marks when the document is edited
|
||||||
// create a mark for each mini location, adding the range to the spec to find it later
|
// create a mark for each mini location, adding the range to the spec to find it later
|
||||||
const marks = e.value
|
const marks = e.value.locations
|
||||||
.filter(([from]) => from < tr.newDoc.length)
|
.filter(([from]) => from < tr.newDoc.length)
|
||||||
.map(([from, to]) => [from, Math.min(to, tr.newDoc.length)])
|
.map(([from, to]) => [from, Math.min(to, tr.newDoc.length)])
|
||||||
.map(
|
.map(
|
||||||
|
|
@ -36,10 +68,10 @@ const miniLocations = StateField.define({
|
||||||
attributes: { style: `background-color: #00CA2880` },
|
attributes: { style: `background-color: #00CA2880` },
|
||||||
}).range(...range), // -> Decoration
|
}).range(...range), // -> Decoration
|
||||||
);
|
);
|
||||||
|
|
||||||
locations = Decoration.set(marks, true); // -> DecorationSet === RangeSet<Decoration>
|
locations = Decoration.set(marks, true); // -> DecorationSet === RangeSet<Decoration>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return locations;
|
return locations;
|
||||||
},
|
},
|
||||||
|
|
@ -75,8 +107,30 @@ const visibleMiniLocations = StateField.define({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const displayMiniLocationsState = StateField.define({
|
||||||
|
create() {
|
||||||
|
return true; // default to showing miniLocations
|
||||||
|
},
|
||||||
|
update(display, tr) {
|
||||||
|
for (let e of tr.effects) {
|
||||||
|
if (e.is(displayMiniLocations)) {
|
||||||
|
display = e.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return display;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// // Derive the set of decorations from the miniLocations and visibleLocations
|
// // Derive the set of decorations from the miniLocations and visibleLocations
|
||||||
const miniLocationHighlights = EditorView.decorations.compute([miniLocations, visibleMiniLocations], (state) => {
|
const miniLocationHighlights = EditorView.decorations.compute(
|
||||||
|
[miniLocations, visibleMiniLocations, displayMiniLocationsState],
|
||||||
|
(state) => {
|
||||||
|
// Check if miniLocations display is disabled
|
||||||
|
const shouldDisplay = state.field(displayMiniLocationsState);
|
||||||
|
if (!shouldDisplay) {
|
||||||
|
return Decoration.none; // Return empty decorations if display is disabled
|
||||||
|
}
|
||||||
|
|
||||||
const iterator = state.field(miniLocations).iter();
|
const iterator = state.field(miniLocations).iter();
|
||||||
const { haps } = state.field(visibleMiniLocations);
|
const { haps } = state.field(visibleMiniLocations);
|
||||||
const builder = new RangeSetBuilder();
|
const builder = new RangeSetBuilder();
|
||||||
|
|
@ -124,15 +178,62 @@ const miniLocationHighlights = EditorView.decorations.compute([miniLocations, vi
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder.finish();
|
return builder.finish();
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const highlightExtension = [miniLocations, visibleMiniLocations, miniLocationHighlights];
|
const getMiniLocationsFromDecorations = (decorations) => {
|
||||||
|
const iterator = decorations.iter();
|
||||||
|
const miniLocationsArray = [];
|
||||||
|
while (iterator.value) {
|
||||||
|
const {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
value: {
|
||||||
|
spec: { id },
|
||||||
|
},
|
||||||
|
} = iterator;
|
||||||
|
miniLocationsArray.push({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
iterator.next();
|
||||||
|
}
|
||||||
|
return miniLocationsArray;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMiniLocations = (state) => {
|
||||||
|
const decorations = state.field(miniLocations);
|
||||||
|
return getMiniLocationsFromDecorations(decorations);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getActiveMiniLocations = (state) => {
|
||||||
|
const miniLocations = getMiniLocations(state);
|
||||||
|
const { haps } = state.field(visibleMiniLocations);
|
||||||
|
|
||||||
|
const activeMiniLocations = miniLocations.filter((location) => haps.has(location.id));
|
||||||
|
return activeMiniLocations;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const highlightExtension = [
|
||||||
|
miniLocations,
|
||||||
|
visibleMiniLocations,
|
||||||
|
displayMiniLocationsState,
|
||||||
|
miniLocationHighlights,
|
||||||
|
];
|
||||||
|
|
||||||
export const isPatternHighlightingEnabled = (on, config) => {
|
export const isPatternHighlightingEnabled = (on, config) => {
|
||||||
on &&
|
// NOTE:
|
||||||
config &&
|
// Modified this function to always return the highlightExtension, and instead just toggle whether or not the miniLocations are displayed.
|
||||||
setTimeout(() => {
|
// This is because block based evaluation only updates regions of miniLocations, and those updates need to be kept track of constantly.
|
||||||
updateMiniLocations(config.editor, config.miniLocations);
|
// The setTimeout was also removed because it conflicted with the range-specific updates required by
|
||||||
}, 100);
|
// block based evaluation.
|
||||||
return on ? Prec.highest(highlightExtension) : [];
|
// Not sure if this is the best approach, but for block based eval I can't think of a better way to do it.
|
||||||
|
|
||||||
|
if (config) {
|
||||||
|
// Toggle the display state for miniLocations
|
||||||
|
config.editor.dispatch({ effects: displayMiniLocations.of(on) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Prec.highest(highlightExtension);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@ import { ref, pure } from '@strudel/core';
|
||||||
import { WidgetType, ViewPlugin, Decoration } from '@codemirror/view';
|
import { WidgetType, ViewPlugin, Decoration } from '@codemirror/view';
|
||||||
import { StateEffect } from '@codemirror/state';
|
import { StateEffect } from '@codemirror/state';
|
||||||
|
|
||||||
|
// Global state storage for all widget types
|
||||||
export let sliderValues = {};
|
export let sliderValues = {};
|
||||||
const getSliderID = (from) => `slider_${from}`;
|
|
||||||
|
|
||||||
export class SliderWidget extends WidgetType {
|
export class SliderWidget extends WidgetType {
|
||||||
constructor(value, min, max, from, to, step, view) {
|
constructor(value, min, max, from, to, step, view, id) {
|
||||||
super();
|
super();
|
||||||
this.value = value;
|
this.value = value;
|
||||||
this.min = min;
|
this.min = min;
|
||||||
|
|
@ -16,11 +16,22 @@ export class SliderWidget extends WidgetType {
|
||||||
this.to = to;
|
this.to = to;
|
||||||
this.step = step;
|
this.step = step;
|
||||||
this.view = view;
|
this.view = view;
|
||||||
|
this.id = id || `${from}:${to}`; // Range-based ID for stability
|
||||||
}
|
}
|
||||||
|
|
||||||
eq() {
|
eq(other) {
|
||||||
|
if (!(other instanceof SliderWidget)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
return (
|
||||||
|
this.id === other.id &&
|
||||||
|
this.from === other.from &&
|
||||||
|
this.to === other.to &&
|
||||||
|
this.value === other.value &&
|
||||||
|
this.min === other.min &&
|
||||||
|
this.max === other.max
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
toDOM() {
|
toDOM() {
|
||||||
let wrap = document.createElement('span');
|
let wrap = document.createElement('span');
|
||||||
|
|
@ -38,6 +49,7 @@ export class SliderWidget extends WidgetType {
|
||||||
slider.from = this.from;
|
slider.from = this.from;
|
||||||
slider.originalFrom = this.originalFrom;
|
slider.originalFrom = this.originalFrom;
|
||||||
slider.to = this.to;
|
slider.to = this.to;
|
||||||
|
slider.id = this.id; // Store range-based ID in DOM element
|
||||||
slider.style = 'width:64px;margin-right:4px;transform:translateY(4px)';
|
slider.style = 'width:64px;margin-right:4px;transform:translateY(4px)';
|
||||||
this.slider = slider;
|
this.slider = slider;
|
||||||
slider.addEventListener('input', (e) => {
|
slider.addEventListener('input', (e) => {
|
||||||
|
|
@ -49,7 +61,7 @@ export class SliderWidget extends WidgetType {
|
||||||
slider.originalValue = insert;
|
slider.originalValue = insert;
|
||||||
slider.value = insert;
|
slider.value = insert;
|
||||||
this.view.dispatch({ changes: change });
|
this.view.dispatch({ changes: change });
|
||||||
const id = getSliderID(slider.originalFrom); // matches id generated in transpiler
|
const id = slider.id; // Use range-based ID
|
||||||
window.postMessage({ type: 'cm-slider', value: Number(next), id });
|
window.postMessage({ type: 'cm-slider', value: Number(next), id });
|
||||||
});
|
});
|
||||||
return wrap;
|
return wrap;
|
||||||
|
|
@ -62,19 +74,60 @@ export class SliderWidget extends WidgetType {
|
||||||
|
|
||||||
export const setSliderWidgets = StateEffect.define();
|
export const setSliderWidgets = StateEffect.define();
|
||||||
|
|
||||||
export const updateSliderWidgets = (view, widgets) => {
|
export const setSliderWidgetsInRange = StateEffect.define();
|
||||||
|
|
||||||
|
export const updateSliderWidgets = (view, widgets, range = null) => {
|
||||||
|
if (range) {
|
||||||
|
// range argument passed for block-based evaluation
|
||||||
|
view.dispatch({ effects: setSliderWidgetsInRange.of({ widgets, range }) });
|
||||||
|
} else {
|
||||||
view.dispatch({ effects: setSliderWidgets.of(widgets) });
|
view.dispatch({ effects: setSliderWidgets.of(widgets) });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function getSliders(widgetConfigs, view) {
|
function getSliders(widgetConfigs, view) {
|
||||||
return widgetConfigs
|
return (
|
||||||
|
widgetConfigs
|
||||||
.filter((w) => w.type === 'slider')
|
.filter((w) => w.type === 'slider')
|
||||||
.map(({ from, to, value, min, max, step }) => {
|
// Deduplicate sliders that might appear multiple times (e.g., during paste operations)
|
||||||
|
.filter((slider, index, self) => index === self.findIndex((s) => s.from === slider.from && s.to === slider.to))
|
||||||
|
.sort((a, b) => a.from - b.from)
|
||||||
|
.map(({ from, to, value, min, max, step, id }) => {
|
||||||
return Decoration.widget({
|
return Decoration.widget({
|
||||||
widget: new SliderWidget(value, min, max, from, to, step, view),
|
widget: new SliderWidget(value, min, max, from, to, step, view, id),
|
||||||
side: 0,
|
side: 0,
|
||||||
}).range(from /* , to */);
|
}).range(from /* , to */);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSliderWidgets(view) {
|
||||||
|
if (!view || !view.state) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sliderPluginInstance = view.plugin(sliderPlugin);
|
||||||
|
if (!sliderPluginInstance || !sliderPluginInstance.decorations) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sliderWidgets = [];
|
||||||
|
|
||||||
|
sliderPluginInstance.decorations.between(0, view.state.doc.length, (from, to, decoration) => {
|
||||||
|
if (decoration.widget instanceof SliderWidget) {
|
||||||
|
sliderWidgets.push({
|
||||||
|
type: 'slider',
|
||||||
|
from: decoration.widget.from,
|
||||||
|
to: decoration.widget.to,
|
||||||
|
value: decoration.widget.value,
|
||||||
|
min: decoration.widget.min,
|
||||||
|
max: decoration.widget.max,
|
||||||
|
step: decoration.widget.step,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return sliderWidgets;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sliderPlugin = ViewPlugin.fromClass(
|
export const sliderPlugin = ViewPlugin.fromClass(
|
||||||
|
|
@ -101,7 +154,42 @@ export const sliderPlugin = ViewPlugin.fromClass(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (let e of tr.effects) {
|
for (let e of tr.effects) {
|
||||||
if (e.is(setSliderWidgets)) {
|
if (e.is(setSliderWidgetsInRange)) {
|
||||||
|
// Block-aware slider update logic
|
||||||
|
const { widgets, range } = e.value;
|
||||||
|
const [rangeStart, rangeEnd] = range;
|
||||||
|
|
||||||
|
// Get existing slider widgets that should be preserved
|
||||||
|
const existingSliders = [];
|
||||||
|
this.decorations.between(0, update.view.state.doc.length, (from, to, decoration) => {
|
||||||
|
if (decoration.widget instanceof SliderWidget) {
|
||||||
|
// Preserve sliders outside the evaluation range
|
||||||
|
// Use strict > for rangeEnd because when code is deleted, slider positions
|
||||||
|
// map to the deletion boundary (rangeEnd), and those should be removed, not preserved
|
||||||
|
if (from < rangeStart || from > rangeEnd) {
|
||||||
|
existingSliders.push({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
value: decoration.widget.value,
|
||||||
|
min: decoration.widget.min,
|
||||||
|
max: decoration.widget.max,
|
||||||
|
step: decoration.widget.step,
|
||||||
|
id: decoration.widget.id || `${from}:${to}`,
|
||||||
|
type: 'slider',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge preserved sliders with new widgets
|
||||||
|
const mergedWidgets = [...existingSliders, ...widgets]
|
||||||
|
.filter(
|
||||||
|
(slider, index, self) => index === self.findIndex((s) => s.type === 'slider' && s.id === slider.id),
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.from - b.from);
|
||||||
|
|
||||||
|
this.decorations = Decoration.set(getSliders(mergedWidgets, update.view));
|
||||||
|
} else if (e.is(setSliderWidgets)) {
|
||||||
this.decorations = Decoration.set(getSliders(e.value, update.view));
|
this.decorations = Decoration.set(getSliders(e.value, update.view));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -132,6 +220,7 @@ export let sliderWithID = (id, value, min, max) => {
|
||||||
sliderValues[id] = value; // sync state at eval time (code -> state)
|
sliderValues[id] = value; // sync state at eval time (code -> state)
|
||||||
return ref(() => sliderValues[id]); // use state at query time
|
return ref(() => sliderValues[id]); // use state at query time
|
||||||
};
|
};
|
||||||
|
|
||||||
// update state when sliders are moved
|
// update state when sliders are moved
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.addEventListener('message', (e) => {
|
window.addEventListener('message', (e) => {
|
||||||
|
|
@ -140,7 +229,7 @@ if (typeof window !== 'undefined') {
|
||||||
// update state when slider is moved
|
// update state when slider is moved
|
||||||
sliderValues[e.data.id] = e.data.value;
|
sliderValues[e.data.id] = e.data.value;
|
||||||
} else {
|
} else {
|
||||||
console.warn(`slider with id "${e.data.id}" is not registered. Only ${Object.keys(sliderValues)}`);
|
console.error(`slider with id "${e.data.id}" is not registered. Only ${Object.keys(sliderValues)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,111 @@
|
||||||
import { StateEffect, StateField } from '@codemirror/state';
|
import { StateEffect, StateField } from '@codemirror/state';
|
||||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
import { Decoration, EditorView, WidgetType, ViewPlugin } from '@codemirror/view';
|
||||||
import { getWidgetID, registerWidgetType } from '@strudel/transpiler';
|
import { getWidgetID, registerWidgetType } from '@strudel/transpiler';
|
||||||
import { Pattern } from '@strudel/core';
|
import { Pattern } from '@strudel/core';
|
||||||
|
|
||||||
export const addWidget = StateEffect.define({
|
export const setWidgets = StateEffect.define();
|
||||||
map: ({ from, to }, change) => {
|
|
||||||
return { from: change.mapPos(from), to: change.mapPos(to) };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const updateWidgets = (view, widgets) => {
|
export const setWidgetsInRange = StateEffect.define();
|
||||||
view.dispatch({ effects: addWidget.of(widgets) });
|
|
||||||
|
export const updateWidgets = (view, widgets, range = null) => {
|
||||||
|
if (range) {
|
||||||
|
// range argument passed for block-based evaluation
|
||||||
|
view.dispatch({ effects: setWidgetsInRange.of({ widgets, range }) });
|
||||||
|
} else {
|
||||||
|
view.dispatch({ effects: setWidgets.of(widgets) });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function getWidgets(widgetConfigs) {
|
function getWidgets(widgetConfigs, view) {
|
||||||
return (
|
const filtered = widgetConfigs
|
||||||
widgetConfigs
|
// Filter to widget configs only (exclude sliders)
|
||||||
// codemirror throws an error if we don't sort
|
.filter((w) => w && w.type && w.type !== 'slider')
|
||||||
.sort((a, b) => a.to - b.to)
|
// Deduplicate widgets by ID, matching slider behavior for stable widget identity
|
||||||
|
.filter((widget, index, self) => index === self.findIndex((w) => w.type === widget.type && w.id === widget.id));
|
||||||
|
|
||||||
|
// Filter out widgets whose range is encompassed by another widget
|
||||||
|
// const nonEncompassed = filterEncompassedWidgets(filtered);
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
.sort((a, b) => (a.to || 0) - (b.to || 0))
|
||||||
.map((widgetConfig) => {
|
.map((widgetConfig) => {
|
||||||
|
try {
|
||||||
return Decoration.widget({
|
return Decoration.widget({
|
||||||
widget: new BlockWidget(widgetConfig),
|
widget: new BlockWidget(widgetConfig, view),
|
||||||
side: 0,
|
side: 0,
|
||||||
block: true,
|
}).range(widgetConfig.to || widgetConfig.from || 0);
|
||||||
}).range(widgetConfig.to);
|
} catch (error) {
|
||||||
|
console.error('error creating widget', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
);
|
.filter(Boolean); // Remove any null results from failed creations
|
||||||
}
|
}
|
||||||
|
|
||||||
const widgetField = StateField.define(
|
export const widgetPlugin = ViewPlugin.fromClass(
|
||||||
/* <DecorationSet> */ {
|
class {
|
||||||
create() {
|
decorations; //: DecorationSet
|
||||||
return Decoration.none;
|
|
||||||
},
|
constructor(view /* : EditorView */) {
|
||||||
update(widgets, tr) {
|
this.decorations = Decoration.set([]);
|
||||||
widgets = widgets.map(tr.changes);
|
}
|
||||||
|
|
||||||
|
update(update /* : ViewUpdate */) {
|
||||||
|
update.transactions.forEach((tr) => {
|
||||||
|
if (tr.docChanged) {
|
||||||
|
this.decorations = this.decorations.map(tr.changes);
|
||||||
|
const iterator = this.decorations.iter();
|
||||||
|
// Apply changes to iterator.from and iterator.to if docChanged
|
||||||
|
while (iterator.value) {
|
||||||
|
// when the widgets are moved, we need to tell the dom node the current position
|
||||||
|
// this is important because the widget functions have to work with the dom node
|
||||||
|
if (iterator.value?.widget instanceof BlockWidget) {
|
||||||
|
iterator.value.widget.from = iterator.from;
|
||||||
|
iterator.value.widget.to = iterator.to;
|
||||||
|
}
|
||||||
|
iterator.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
for (let e of tr.effects) {
|
for (let e of tr.effects) {
|
||||||
if (e.is(addWidget)) {
|
if (e.is(setWidgetsInRange)) {
|
||||||
try {
|
// Block-aware widget update logic
|
||||||
widgets = widgets.update({
|
const { widgets, range } = e.value;
|
||||||
filter: () => false,
|
const [rangeStart, rangeEnd] = range;
|
||||||
add: getWidgets(e.value),
|
|
||||||
|
// Get existing widget widgets that should be preserved
|
||||||
|
const existingWidgets = [];
|
||||||
|
this.decorations.between(0, update.view.state.doc.length, (from, to, decoration) => {
|
||||||
|
if (decoration.widget instanceof BlockWidget) {
|
||||||
|
// Preserve widgets outside the evaluation range
|
||||||
|
// Use strict > for rangeEnd because when code is deleted, widget positions
|
||||||
|
// map to the deletion boundary (rangeEnd), and those should be removed, not preserved
|
||||||
|
if (from < rangeStart || from > rangeEnd) {
|
||||||
|
existingWidgets.push({
|
||||||
|
from: decoration.widget.from,
|
||||||
|
to: decoration.widget.to,
|
||||||
|
type: decoration.widget.type,
|
||||||
|
index: decoration.widget.index,
|
||||||
|
id: decoration.widget.id,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
console.log('err', error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge preserved widgets with new widgets, deduplicating by ID
|
||||||
|
const mergedWidgets = [...existingWidgets, ...widgets].filter(
|
||||||
|
(widget, index, self) => index === self.findIndex((w) => w.type === widget.type && w.id === widget.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.decorations = Decoration.set(getWidgets(mergedWidgets, update.view));
|
||||||
|
} else if (e.is(setWidgets)) {
|
||||||
|
this.decorations = Decoration.set(getWidgets(e.value, update.view));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return widgets;
|
|
||||||
},
|
},
|
||||||
provide: (f) => EditorView.decorations.from(f),
|
{
|
||||||
|
decorations: (v) => v.decorations,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -60,24 +116,116 @@ export function setWidget(id, el) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BlockWidget extends WidgetType {
|
export class BlockWidget extends WidgetType {
|
||||||
constructor(widgetConfig) {
|
constructor(widgetConfig, view) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
|
// Graceful handling of invalid configs like sliders
|
||||||
|
if (!widgetConfig || typeof widgetConfig !== 'object') {
|
||||||
|
widgetConfig = { type: 'unknown', from: 0, to: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.from = widgetConfig.from || 0;
|
||||||
|
this.originalFrom = widgetConfig.from || 0;
|
||||||
|
this.to = widgetConfig.to || this.from;
|
||||||
|
this.originalTo = widgetConfig.to || this.from;
|
||||||
|
this.type = widgetConfig.type || 'unknown';
|
||||||
|
this.index = widgetConfig.index || 0;
|
||||||
|
this.view = view;
|
||||||
|
|
||||||
|
// Use range-based ID for stability, similar to sliders
|
||||||
|
this.id = widgetConfig.id || getWidgetID?.(widgetConfig);
|
||||||
this.widgetConfig = widgetConfig;
|
this.widgetConfig = widgetConfig;
|
||||||
}
|
}
|
||||||
eq() {
|
|
||||||
return true;
|
eq(other) {
|
||||||
|
if (!(other instanceof BlockWidget)) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
return (
|
||||||
|
this.id === other.id &&
|
||||||
|
this.from === other.from &&
|
||||||
|
this.to === other.to &&
|
||||||
|
this.type === other.type &&
|
||||||
|
this.index === other.index
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
toDOM() {
|
toDOM() {
|
||||||
const id = getWidgetID(this.widgetConfig);
|
let wrap = document.createElement('span');
|
||||||
const el = widgetElements[id];
|
wrap.setAttribute('aria-hidden', 'true');
|
||||||
return el;
|
wrap.className = 'cm-widget-container';
|
||||||
|
|
||||||
|
let el = widgetElements[this.id];
|
||||||
|
if (el) {
|
||||||
|
// Ensure the element has the correct ID
|
||||||
|
el.id = this.id;
|
||||||
|
wrap.appendChild(el);
|
||||||
|
} else {
|
||||||
|
// Create a placeholder element if the widget element doesn't exist
|
||||||
|
// This prevents CodeMirror errors when widget is missing
|
||||||
|
const placeholder = document.createElement('span');
|
||||||
|
placeholder.setAttribute('aria-hidden', 'true');
|
||||||
|
placeholder.className = 'cm-widget-placeholder';
|
||||||
|
placeholder.style.cssText = 'display: none;'; // Hide placeholder
|
||||||
|
placeholder.id = this.id;
|
||||||
|
wrap.appendChild(placeholder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
ignoreEvent(e) {
|
ignoreEvent(e) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const widgetPlugin = [widgetField];
|
export function getActiveWidgets(view) {
|
||||||
|
if (!view || !view.state) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const widgetPluginInstance = view.plugin(widgetPlugin);
|
||||||
|
if (!widgetPluginInstance || !widgetPluginInstance.decorations) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const widgets = [];
|
||||||
|
|
||||||
|
widgetPluginInstance.decorations.between(0, view.state.doc.length, (from, to, decoration) => {
|
||||||
|
if (decoration.widget instanceof BlockWidget) {
|
||||||
|
widgets.push({
|
||||||
|
type: decoration.widget.type,
|
||||||
|
from: decoration.widget.from,
|
||||||
|
to: decoration.widget.to,
|
||||||
|
index: decoration.widget.index,
|
||||||
|
id: decoration.widget.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return widgets;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllWidgetIds(view) {
|
||||||
|
if (!view || !view.state) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const widgetPluginInstance = view.plugin(widgetPlugin);
|
||||||
|
if (!widgetPluginInstance || !widgetPluginInstance.decorations) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const widgetIds = [];
|
||||||
|
|
||||||
|
widgetPluginInstance.decorations.between(0, view.state.doc.length, (from, to, decoration) => {
|
||||||
|
if (decoration.widget instanceof BlockWidget) {
|
||||||
|
widgetIds.push(decoration.widget.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return widgetIds;
|
||||||
|
}
|
||||||
|
|
||||||
// widget implementer API to create a new widget type
|
// widget implementer API to create a new widget type
|
||||||
export function registerWidget(type, fn) {
|
export function registerWidget(type, fn) {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,34 @@ This program is free software: you can redistribute it and/or modify it under th
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const strudelScope = {};
|
export const strudelScope = {};
|
||||||
|
// Make strudelScope available globally so transpiled code can access it
|
||||||
|
globalThis.strudelScope = strudelScope;
|
||||||
|
|
||||||
|
// Track user-defined keys (from block-based eval) so we can clear them without removing strudel functions
|
||||||
|
export const userDefinedKeys = new Set();
|
||||||
|
globalThis.userDefinedKeys = userDefinedKeys;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all user-defined variables and functions from the scope.
|
||||||
|
* This removes variables created during block-based evaluation.
|
||||||
|
* @name clearScope
|
||||||
|
* @example
|
||||||
|
* // After defining variables in blocks:
|
||||||
|
* // let myVar = 5
|
||||||
|
* // function myFunc() { return 10; }
|
||||||
|
* clearScope() // removes myVar and myFunc from scope
|
||||||
|
*/
|
||||||
|
export const clearScope = () => {
|
||||||
|
for (const key of userDefinedKeys) {
|
||||||
|
delete strudelScope[key];
|
||||||
|
delete globalThis[key];
|
||||||
|
}
|
||||||
|
userDefinedKeys.clear();
|
||||||
|
// Return silence if available (for use in pattern expressions), otherwise undefined
|
||||||
|
return globalThis.silence;
|
||||||
|
};
|
||||||
|
// Make clearScope available globally
|
||||||
|
globalThis.clearScope = clearScope;
|
||||||
|
|
||||||
export const evalScope = async (...args) => {
|
export const evalScope = async (...args) => {
|
||||||
const results = await Promise.allSettled(args);
|
const results = await Promise.allSettled(args);
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ export function repl({
|
||||||
pattern: undefined,
|
pattern: undefined,
|
||||||
miniLocations: [],
|
miniLocations: [],
|
||||||
widgets: [],
|
widgets: [],
|
||||||
|
sliders: [],
|
||||||
pending: false,
|
pending: false,
|
||||||
started: false,
|
started: false,
|
||||||
};
|
};
|
||||||
|
|
@ -79,11 +80,132 @@ export function repl({
|
||||||
let allTransform;
|
let allTransform;
|
||||||
let eachTransform;
|
let eachTransform;
|
||||||
|
|
||||||
|
// Block-based evaluation state
|
||||||
|
let codeBlocks = {};
|
||||||
|
let lastActiveVisualizerLabel = null;
|
||||||
|
// Track which patterns belong to which blocks: { blockRange: [patternKeys] }
|
||||||
|
let blockPatterns = new Map();
|
||||||
|
|
||||||
|
// Helper function to collect properties from all code blocks (handles both labeled and anonymous blocks)
|
||||||
|
function collectFromBlocks(property) {
|
||||||
|
return Object.entries(codeBlocks).flatMap(([key, block]) => {
|
||||||
|
if (key === '$') {
|
||||||
|
// Anonymous blocks are stored as an array of block objects
|
||||||
|
return Array.isArray(block) ? block.flatMap((b) => b[property] || []) : [];
|
||||||
|
}
|
||||||
|
// Labeled blocks are stored as single block objects
|
||||||
|
return block[property] || [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to process a single labeled block
|
||||||
|
function processLabeledBlock(labels, i, code, options, meta) {
|
||||||
|
const label = labels[i];
|
||||||
|
const nextLabel = labels[i + 1] || { index: code.length, end: code.length };
|
||||||
|
|
||||||
|
const labelCode = code.slice(label.index, nextLabel.index);
|
||||||
|
const labelRange = [label.index + options.range[0], label.end + options.range[0]];
|
||||||
|
|
||||||
|
// Calculate the full block range (from label start to next label start)
|
||||||
|
const blockStart = label.index + options.range[0];
|
||||||
|
const blockEnd = nextLabel.index + options.range[0];
|
||||||
|
|
||||||
|
const blockWidgets = (meta?.widgets || []).filter((widget) => {
|
||||||
|
const widgetPos = widget.from ?? widget.index ?? 0;
|
||||||
|
return widgetPos >= blockStart && widgetPos < blockEnd;
|
||||||
|
});
|
||||||
|
|
||||||
|
const blockSliders = (meta?.sliders || []).filter((slider) => {
|
||||||
|
const sliderPos = slider.from ?? slider.index ?? 0;
|
||||||
|
return sliderPos >= blockStart && sliderPos < blockEnd;
|
||||||
|
});
|
||||||
|
|
||||||
|
const blockMiniLocations = (meta?.miniLocations || []).filter((loc) => {
|
||||||
|
// const locStart = loc.start ?? loc.from ?? 0;
|
||||||
|
// mini locations can be either [start, end] arrays or objects with start/from
|
||||||
|
const locStart = Array.isArray(loc) ? loc[0] : (loc.start ?? loc.from ?? 0);
|
||||||
|
return locStart >= blockStart && locStart < blockEnd;
|
||||||
|
});
|
||||||
|
|
||||||
|
handleSingleLabelBlock(
|
||||||
|
label,
|
||||||
|
labelCode,
|
||||||
|
{ ...options, range: labelRange },
|
||||||
|
{ widgets: blockWidgets, sliders: blockSliders, miniLocations: blockMiniLocations },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// helper
|
||||||
|
function cleanupConflictingRanges(codeBlocks, currentKey, newRange) {
|
||||||
|
for (const [existingKey, existingBlock] of Object.entries(codeBlocks)) {
|
||||||
|
if (existingKey === currentKey) continue;
|
||||||
|
if (!existingBlock.range) continue;
|
||||||
|
|
||||||
|
const [existingStart, existingEnd] = existingBlock.range;
|
||||||
|
const [newStart, newEnd] = newRange;
|
||||||
|
|
||||||
|
// If ranges overlap (not just touch), remove the stale block
|
||||||
|
if (!(newEnd <= existingStart || newStart >= existingEnd)) {
|
||||||
|
delete codeBlocks[existingKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// helper
|
||||||
|
function handleSingleLabelBlock(label, code, options, meta) {
|
||||||
|
// Detect if this block contains a non-inline widget
|
||||||
|
// The activeVisualizer is now provided by the transpiler for all labels
|
||||||
|
const activeVisualizer = label.activeVisualizer || null;
|
||||||
|
|
||||||
|
if (activeVisualizer !== null) {
|
||||||
|
lastActiveVisualizerLabel = label.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the entire code block under the label name
|
||||||
|
codeBlocks[label.name] = {
|
||||||
|
code: code,
|
||||||
|
range: options.range,
|
||||||
|
labels: [label.name],
|
||||||
|
miniLocations: meta?.miniLocations || [],
|
||||||
|
widgets: meta?.widgets || [],
|
||||||
|
sliders: meta?.sliders || [],
|
||||||
|
activeVisualizer: activeVisualizer, // Store the widget type if present, null otherwise
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clean up any blocks with conflicting ranges (including declaration blocks)
|
||||||
|
cleanupConflictingRanges(codeBlocks, label.name, options.range);
|
||||||
|
}
|
||||||
|
|
||||||
|
// helper
|
||||||
|
// These blocks return silence but may contain mini notation strings that need highlighting
|
||||||
|
function handleDeclarationBlock(code, options, meta) {
|
||||||
|
const range = options.range || [];
|
||||||
|
if (range.length < 2) return;
|
||||||
|
|
||||||
|
const blockKey = `_decl:${range[0]}:${range[1]}`;
|
||||||
|
|
||||||
|
codeBlocks[blockKey] = {
|
||||||
|
code: code,
|
||||||
|
range: range,
|
||||||
|
labels: [],
|
||||||
|
miniLocations: meta?.miniLocations || [],
|
||||||
|
widgets: meta?.widgets || [],
|
||||||
|
sliders: meta?.sliders || [],
|
||||||
|
activeVisualizer: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clean up any overlapping declaration blocks
|
||||||
|
cleanupConflictingRanges(codeBlocks, blockKey, range);
|
||||||
|
}
|
||||||
|
|
||||||
const hush = function () {
|
const hush = function () {
|
||||||
pPatterns = {};
|
pPatterns = {};
|
||||||
anonymousIndex = 0;
|
anonymousIndex = 0;
|
||||||
allTransform = undefined;
|
allTransform = undefined;
|
||||||
eachTransform = undefined;
|
eachTransform = undefined;
|
||||||
|
codeBlocks = {};
|
||||||
|
blockPatterns.clear();
|
||||||
|
lastActiveVisualizerLabel = null; // Reset 'all' visualizer tracking
|
||||||
return silence;
|
return silence;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -103,7 +225,60 @@ export function repl({
|
||||||
};
|
};
|
||||||
setTime(() => scheduler.now()); // TODO: refactor?
|
setTime(() => scheduler.now()); // TODO: refactor?
|
||||||
|
|
||||||
const stop = () => scheduler.stop();
|
// Helper function to apply pattern transformations (solo, each, all)
|
||||||
|
// this should be abstracted more
|
||||||
|
function applyPatternTransforms(pattern) {
|
||||||
|
const allPatterns = Object.values(pPatterns);
|
||||||
|
|
||||||
|
if (allPatterns.length) {
|
||||||
|
let patterns = [];
|
||||||
|
let soloActive = false;
|
||||||
|
for (const [key, value] of Object.entries(pPatterns)) {
|
||||||
|
// handle soloed patterns ex: S$: s("bd!4")
|
||||||
|
const isSolod = key.length > 1 && key.startsWith('S');
|
||||||
|
if (isSolod && soloActive === false) {
|
||||||
|
// first time we see a soloed pattern, clear existing patterns
|
||||||
|
patterns = [];
|
||||||
|
soloActive = true;
|
||||||
|
}
|
||||||
|
if (!soloActive || (soloActive && isSolod)) {
|
||||||
|
const valWithState = value.withState((state) => state.setControls({ id: key }));
|
||||||
|
patterns.push(valWithState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (eachTransform) {
|
||||||
|
// Explicit lambda so only element (not index and array) are passed
|
||||||
|
patterns = patterns.map((x) => eachTransform(x));
|
||||||
|
}
|
||||||
|
pattern = stack(...patterns);
|
||||||
|
} else if (eachTransform) {
|
||||||
|
pattern = eachTransform(pattern);
|
||||||
|
}
|
||||||
|
if (allTransforms.length) {
|
||||||
|
for (const transform of allTransforms) {
|
||||||
|
pattern = transform(pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPattern(pattern)) {
|
||||||
|
pattern = silence;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
codeBlocks = {};
|
||||||
|
blockPatterns.clear();
|
||||||
|
pPatterns = {};
|
||||||
|
lastActiveVisualizerLabel = null; // Reset 'all' visualizer tracking
|
||||||
|
updateState({
|
||||||
|
miniLocations: [],
|
||||||
|
widgets: [],
|
||||||
|
sliders: [],
|
||||||
|
});
|
||||||
|
scheduler.stop();
|
||||||
|
};
|
||||||
const start = () => scheduler.start();
|
const start = () => scheduler.start();
|
||||||
const pause = () => scheduler.pause();
|
const pause = () => scheduler.pause();
|
||||||
const toggle = () => scheduler.toggle();
|
const toggle = () => scheduler.toggle();
|
||||||
|
|
@ -217,7 +392,7 @@ export function repl({
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const evaluate = async (code, autostart = true, shouldHush = true) => {
|
const evaluate = async (code, autostart = true) => {
|
||||||
if (!code) {
|
if (!code) {
|
||||||
throw new Error('no code to evaluate');
|
throw new Error('no code to evaluate');
|
||||||
}
|
}
|
||||||
|
|
@ -225,59 +400,34 @@ export function repl({
|
||||||
updateState({ code, pending: true });
|
updateState({ code, pending: true });
|
||||||
await injectPatternMethods();
|
await injectPatternMethods();
|
||||||
setTime(() => scheduler.now()); // TODO: refactor?
|
setTime(() => scheduler.now()); // TODO: refactor?
|
||||||
await beforeEval?.({ code });
|
await beforeEval?.({ code, blockBased: false });
|
||||||
allTransforms = []; // reset all transforms
|
allTransforms = []; // reset all transforms
|
||||||
shouldHush && hush();
|
|
||||||
|
codeBlocks = {};
|
||||||
|
hush();
|
||||||
|
|
||||||
if (mondo) {
|
if (mondo) {
|
||||||
code = `mondolang\`${code}\``;
|
code = `mondolang\`${code}\``;
|
||||||
}
|
}
|
||||||
let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptions);
|
|
||||||
if (Object.keys(pPatterns).length) {
|
|
||||||
let patterns = [];
|
|
||||||
let soloActive = false;
|
|
||||||
for (const [key, value] of Object.entries(pPatterns)) {
|
|
||||||
// handle soloed patterns ex: S$: s("bd!4")
|
|
||||||
const isSolod = key.length > 1 && key.startsWith('S');
|
|
||||||
if (isSolod && soloActive === false) {
|
|
||||||
// first time we see a soloed pattern, clear existing patterns
|
|
||||||
patterns = [];
|
|
||||||
soloActive = true;
|
|
||||||
}
|
|
||||||
if (!soloActive || (soloActive && isSolod)) {
|
|
||||||
const valWithState = value.withState((state) => state.setControls({ id: key }));
|
|
||||||
patterns.push(valWithState);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (eachTransform) {
|
|
||||||
// Explicit lambda so only element (not index and array) are passed
|
|
||||||
patterns = patterns.map((x) => eachTransform(x));
|
|
||||||
}
|
|
||||||
pattern = stack(...patterns);
|
|
||||||
} else if (eachTransform) {
|
|
||||||
pattern = eachTransform(pattern);
|
|
||||||
}
|
|
||||||
if (allTransforms.length) {
|
|
||||||
for (const transform of allTransforms) {
|
|
||||||
pattern = transform(pattern);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isPattern(pattern)) {
|
let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptions);
|
||||||
pattern = silence;
|
|
||||||
}
|
pattern = applyPatternTransforms(pattern);
|
||||||
|
|
||||||
logger(`[eval] code updated`);
|
logger(`[eval] code updated`);
|
||||||
pattern = await setPattern(pattern, autostart);
|
pattern = await setPattern(pattern, autostart);
|
||||||
updateState({
|
updateState({
|
||||||
miniLocations: meta?.miniLocations || [],
|
miniLocations: meta?.miniLocations || [],
|
||||||
widgets: meta?.widgets || [],
|
widgets: meta?.widgets || [],
|
||||||
|
sliders: meta?.sliders || [],
|
||||||
activeCode: code,
|
activeCode: code,
|
||||||
pattern,
|
pattern,
|
||||||
evalError: undefined,
|
evalError: undefined,
|
||||||
schedulerError: undefined,
|
schedulerError: undefined,
|
||||||
pending: false,
|
pending: false,
|
||||||
});
|
});
|
||||||
afterEval?.({ code, pattern, meta });
|
|
||||||
|
afterEval?.({ code, pattern, meta, range: undefined, widgetRemoved: false });
|
||||||
return pattern;
|
return pattern;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger(`[eval] error: ${err.message}`, 'error');
|
logger(`[eval] error: ${err.message}`, 'error');
|
||||||
|
|
@ -286,8 +436,121 @@ export function repl({
|
||||||
onEvalError?.(err);
|
onEvalError?.(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const evaluateBlock = async (code, autostart = true, options = {}) => {
|
||||||
|
if (!code) {
|
||||||
|
throw new Error('no code to evaluate');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
updateState({ code, pending: true });
|
||||||
|
await injectPatternMethods();
|
||||||
|
setTime(() => scheduler.now()); // TODO: refactor?
|
||||||
|
await beforeEval?.({ code, blockBased: true });
|
||||||
|
allTransforms = []; // reset all transforms
|
||||||
|
|
||||||
|
const transpilerOptionsWithBlock = {
|
||||||
|
...transpilerOptions,
|
||||||
|
blockBased: true,
|
||||||
|
range: options.range || [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mondo) {
|
||||||
|
code = `mondolang\`${code}\``;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptionsWithBlock);
|
||||||
|
|
||||||
|
// Track activeVisualizer cleanup: check if any block's visualizer was removed
|
||||||
|
let widgetRemoved = false;
|
||||||
|
|
||||||
|
const labels = meta.labels || [];
|
||||||
|
|
||||||
|
// Check for anonymous labels (labels starting with '$')
|
||||||
|
const hasAnonymousLabel = labels.some((label) => label.name.startsWith('$'));
|
||||||
|
|
||||||
|
// Store code blocks in dictionary using labels as keys
|
||||||
|
if (hasAnonymousLabel) {
|
||||||
|
// variable/function declarations that don't return patterns are allowed,
|
||||||
|
// but anonymous pattern blocks pose an issue for block-based evaluation
|
||||||
|
// if an anonymous pattern is evaluated multiple times it will just stack and get louder and louder
|
||||||
|
|
||||||
|
// it's very common for users to write code prefixed with '$'
|
||||||
|
// but to modify and override existing patterns, the patterns must be labeled,
|
||||||
|
// otherwise we'll have no idea of which pattern is being overridden
|
||||||
|
|
||||||
|
// (we probably need to update the docs on this)
|
||||||
|
// we could easily enable it, but it would confuse a lot of people
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
'anonymous labels disabled for block based evaluation (see https://strudel.cc/blog/#label-notation)',
|
||||||
|
);
|
||||||
|
} else if (labels.length > 0) {
|
||||||
|
for (let i = 0; i < labels.length; i++) {
|
||||||
|
// processing transpiler output instead of code is simply to avoid
|
||||||
|
// extra regex in detecting whether or not an inline widget has been commented out
|
||||||
|
processLabeledBlock(labels, i, meta.output, options, meta);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Declaration block (variable/function that returns silence)
|
||||||
|
// Store it so its miniLocations are preserved for highlighting patterns stored in variables
|
||||||
|
handleDeclarationBlock(code, options, meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
meta.miniLocations = collectFromBlocks('miniLocations');
|
||||||
|
meta.widgets = collectFromBlocks('widgets');
|
||||||
|
meta.sliders = collectFromBlocks('sliders');
|
||||||
|
|
||||||
|
// Track activeVisualizer cleanup: check if any block's visualizer was removed
|
||||||
|
const blocksToUpdate = labels.map((label) => label.name);
|
||||||
|
|
||||||
|
// this is the hackiest bit
|
||||||
|
for (const [key, block] of Object.entries(codeBlocks)) {
|
||||||
|
if (blocksToUpdate.includes(key)) {
|
||||||
|
// This block was just updated
|
||||||
|
if (block.activeVisualizer !== null) {
|
||||||
|
// Block now has a visualizer, update tracking
|
||||||
|
lastActiveVisualizerLabel = key;
|
||||||
|
} else if (lastActiveVisualizerLabel === key) {
|
||||||
|
// This block lost its visualizer, trigger cleanup
|
||||||
|
widgetRemoved = true;
|
||||||
|
lastActiveVisualizerLabel = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pPatterns = Object.fromEntries(
|
||||||
|
Object.entries(pPatterns).filter(([key]) => {
|
||||||
|
return Object.keys(codeBlocks).includes(key);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
pattern = applyPatternTransforms(pattern);
|
||||||
|
|
||||||
|
logger(`[eval] code updated`);
|
||||||
|
pattern = await setPattern(pattern, autostart);
|
||||||
|
updateState({
|
||||||
|
miniLocations: meta?.miniLocations || [],
|
||||||
|
widgets: meta?.widgets || [],
|
||||||
|
sliders: meta?.sliders || [],
|
||||||
|
activeCode: code,
|
||||||
|
pattern,
|
||||||
|
evalError: undefined,
|
||||||
|
schedulerError: undefined,
|
||||||
|
pending: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEval?.({ code, pattern, meta, range: options.range, widgetRemoved });
|
||||||
|
return pattern;
|
||||||
|
} catch (err) {
|
||||||
|
logger(`[eval] error: ${err.message}`, 'error');
|
||||||
|
console.error(err);
|
||||||
|
updateState({ evalError: err, pending: false });
|
||||||
|
onEvalError?.(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const setCode = (code) => updateState({ code });
|
const setCode = (code) => updateState({ code });
|
||||||
return { scheduler, evaluate, start, stop, pause, setCps, setPattern, setCode, toggle, state };
|
return { scheduler, evaluate, evaluateBlock, start, stop, pause, setCps, setPattern, setCode, toggle, state };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getTrigger =
|
export const getTrigger =
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,16 @@ export const cleanupDraw = (clearScreen = true, id) => {
|
||||||
stopAllAnimations(id);
|
stopAllAnimations(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const cleanupDrawContext = (replID) => {
|
||||||
|
const ctx = getDrawContext();
|
||||||
|
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||||
|
|
||||||
|
// clear the big canvas context, ignore inline widgets
|
||||||
|
Object.keys(animationFrames).forEach(
|
||||||
|
(id) => (!replID || id.startsWith(replID)) && !id.startsWith('_') && stopAnimationFrame(id),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
Pattern.prototype.onPaint = function (painter) {
|
Pattern.prototype.onPaint = function (painter) {
|
||||||
return this.withState((state) => {
|
return this.withState((state) => {
|
||||||
if (!state.controls.painters) {
|
if (!state.controls.painters) {
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,14 @@ export function registerLanguage(type, config) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function transpiler(input, options = {}) {
|
export function transpiler(input, options = {}) {
|
||||||
const { wrapAsync = false, addReturn = true, emitMiniLocations = true, emitWidgets = true } = options;
|
const {
|
||||||
|
wrapAsync = false,
|
||||||
|
addReturn = true,
|
||||||
|
emitMiniLocations = true,
|
||||||
|
emitWidgets = true,
|
||||||
|
blockBased = false,
|
||||||
|
range = [],
|
||||||
|
} = options;
|
||||||
|
|
||||||
const comments = [];
|
const comments = [];
|
||||||
let ast = parse(input, {
|
let ast = parse(input, {
|
||||||
|
|
@ -31,6 +38,13 @@ export function transpiler(input, options = {}) {
|
||||||
|
|
||||||
const miniDisableRanges = findMiniDisableRanges(comments, input.length);
|
const miniDisableRanges = findMiniDisableRanges(comments, input.length);
|
||||||
let miniLocations = [];
|
let miniLocations = [];
|
||||||
|
|
||||||
|
// Position offset for block-based evaluation
|
||||||
|
let nodeOffset = range && range.length > 0 ? range[0] : 0;
|
||||||
|
|
||||||
|
// Track declarations to add to strudelScope for block-based eval
|
||||||
|
let scopeDeclarations = [];
|
||||||
|
|
||||||
const collectMiniLocations = (value, node) => {
|
const collectMiniLocations = (value, node) => {
|
||||||
const minilang = languages.get('minilang');
|
const minilang = languages.get('minilang');
|
||||||
if (minilang) {
|
if (minilang) {
|
||||||
|
|
@ -43,9 +57,28 @@ export function transpiler(input, options = {}) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let widgets = [];
|
let widgets = [];
|
||||||
|
let sliders = [];
|
||||||
|
let labels = [];
|
||||||
|
|
||||||
walk(ast, {
|
walk(ast, {
|
||||||
enter(node, parent /* , prop, index */) {
|
enter(node, parent /* , prop, index */) {
|
||||||
|
// Apply position offset for block-based evaluation
|
||||||
|
if (blockBased && node.start !== undefined) {
|
||||||
|
node.start = node.start + nodeOffset;
|
||||||
|
node.end = node.end + nodeOffset;
|
||||||
|
}
|
||||||
|
// Collect variable and function declarations for strudelScope (block-based eval)
|
||||||
|
if (blockBased && parent?.type === 'Program') {
|
||||||
|
if (node.type === 'VariableDeclaration') {
|
||||||
|
for (const declarator of node.declarations) {
|
||||||
|
if (declarator.id?.name) {
|
||||||
|
scopeDeclarations.push(declarator.id.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (node.type === 'FunctionDeclaration' && node.id?.name) {
|
||||||
|
scopeDeclarations.push(node.id.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (isLanguageLiteral(node)) {
|
if (isLanguageLiteral(node)) {
|
||||||
const { name } = node.tag;
|
const { name } = node.tag;
|
||||||
const language = languages.get(name);
|
const language = languages.get(name);
|
||||||
|
|
@ -88,22 +121,29 @@ export function transpiler(input, options = {}) {
|
||||||
return this.replace(miniWithLocation(value, node));
|
return this.replace(miniWithLocation(value, node));
|
||||||
}
|
}
|
||||||
if (isSliderFunction(node)) {
|
if (isSliderFunction(node)) {
|
||||||
emitWidgets &&
|
const from = node.arguments[0].start + nodeOffset;
|
||||||
widgets.push({
|
const to = node.arguments[0].end + nodeOffset;
|
||||||
from: node.arguments[0].start,
|
const id = `${from}:${to}`; // Range-based ID for stability
|
||||||
to: node.arguments[0].end,
|
|
||||||
|
const sliderConfig = {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
id,
|
||||||
value: node.arguments[0].raw, // don't use value!
|
value: node.arguments[0].raw, // don't use value!
|
||||||
min: node.arguments[1]?.value ?? 0,
|
min: node.arguments[1]?.value ?? 0,
|
||||||
max: node.arguments[2]?.value ?? 1,
|
max: node.arguments[2]?.value ?? 1,
|
||||||
step: node.arguments[3]?.value,
|
step: node.arguments[3]?.value,
|
||||||
type: 'slider',
|
type: 'slider',
|
||||||
});
|
};
|
||||||
return this.replace(sliderWithLocation(node));
|
emitWidgets && widgets.push(sliderConfig);
|
||||||
|
sliders.push(sliderConfig);
|
||||||
|
return this.replace(sliderWithLocation(node, nodeOffset));
|
||||||
}
|
}
|
||||||
if (isWidgetMethod(node)) {
|
if (isWidgetMethod(node)) {
|
||||||
const type = node.callee.property.name;
|
const type = node.callee.property.name;
|
||||||
const index = widgets.filter((w) => w.type === type).length;
|
const index = widgets.filter((w) => w.type === type).length;
|
||||||
const widgetConfig = {
|
const widgetConfig = {
|
||||||
|
from: node.start,
|
||||||
to: node.end,
|
to: node.end,
|
||||||
index,
|
index,
|
||||||
type,
|
type,
|
||||||
|
|
@ -116,8 +156,30 @@ export function transpiler(input, options = {}) {
|
||||||
return this.replace(withAwait(node));
|
return this.replace(withAwait(node));
|
||||||
}
|
}
|
||||||
if (isLabelStatement(node)) {
|
if (isLabelStatement(node)) {
|
||||||
|
// Collect label info for block-based evaluation
|
||||||
|
// Store positions WITHOUT offset so repl can slice the transpiler output correctly
|
||||||
|
if (blockBased) {
|
||||||
|
labels.push({
|
||||||
|
name: node.label.name,
|
||||||
|
index: node.start - nodeOffset,
|
||||||
|
end: node.label.end - nodeOffset,
|
||||||
|
fullMatch: input.slice(node.start - nodeOffset, node.label.end - nodeOffset),
|
||||||
|
activeVisualizer: findVisualizerInSubtree(node.body),
|
||||||
|
});
|
||||||
|
}
|
||||||
return this.replace(labelToP(node));
|
return this.replace(labelToP(node));
|
||||||
}
|
}
|
||||||
|
// Detect all() calls as special labels for block management
|
||||||
|
// Store positions WITHOUT offset so repl can slice the transpiler output correctly
|
||||||
|
if (blockBased && isAllCall(node)) {
|
||||||
|
labels.push({
|
||||||
|
name: 'all',
|
||||||
|
index: node.start - nodeOffset,
|
||||||
|
end: node.end - nodeOffset,
|
||||||
|
fullMatch: input.slice(node.start - nodeOffset, node.end - nodeOffset),
|
||||||
|
activeVisualizer: node.arguments[0] ? findVisualizerInSubtree(node.arguments[0]) : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
leave(node, parent, prop, index) {
|
leave(node, parent, prop, index) {
|
||||||
|
|
@ -181,18 +243,34 @@ export function transpiler(input, options = {}) {
|
||||||
|
|
||||||
let { body } = ast;
|
let { body } = ast;
|
||||||
|
|
||||||
if (!body.length) {
|
const silenceExpression = {
|
||||||
console.warn('empty body -> fallback to silence');
|
|
||||||
body.push({
|
|
||||||
type: 'ExpressionStatement',
|
type: 'ExpressionStatement',
|
||||||
expression: {
|
expression: {
|
||||||
type: 'Identifier',
|
type: 'Identifier',
|
||||||
name: 'silence',
|
name: 'silence',
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (!body.length) {
|
||||||
|
console.warn('empty body -> fallback to silence');
|
||||||
|
body.push(silenceExpression);
|
||||||
} else if (!body?.[body.length - 1]?.expression) {
|
} else if (!body?.[body.length - 1]?.expression) {
|
||||||
|
// Last statement is not an expression (e.g., VariableDeclaration, FunctionDeclaration)
|
||||||
|
if (blockBased) {
|
||||||
|
// For block-based eval, add silence as the return value when block ends with declaration
|
||||||
|
body.push(silenceExpression);
|
||||||
|
} else {
|
||||||
throw new Error('unexpected ast format without body expression');
|
throw new Error('unexpected ast format without body expression');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For block-based eval, add scope assignments before the return statement
|
||||||
|
// This allows variables/functions defined in one block to be used in other blocks
|
||||||
|
if (blockBased && scopeDeclarations.length > 0) {
|
||||||
|
const scopeAssignments = scopeDeclarations.flatMap((name) => createScopeAssignment(name));
|
||||||
|
// Insert scope assignments before the last statement (which will become the return)
|
||||||
|
body.splice(body.length - 1, 0, ...scopeAssignments);
|
||||||
|
}
|
||||||
|
|
||||||
// add return to last statement
|
// add return to last statement
|
||||||
if (addReturn) {
|
if (addReturn) {
|
||||||
|
|
@ -209,7 +287,7 @@ export function transpiler(input, options = {}) {
|
||||||
if (!emitMiniLocations) {
|
if (!emitMiniLocations) {
|
||||||
return { output };
|
return { output };
|
||||||
}
|
}
|
||||||
return { output, miniLocations, widgets };
|
return { output, miniLocations, widgets, sliders, labels };
|
||||||
}
|
}
|
||||||
|
|
||||||
function isKabelCall(node) {
|
function isKabelCall(node) {
|
||||||
|
|
@ -416,8 +494,14 @@ function isWidgetMethod(node) {
|
||||||
return node.type === 'CallExpression' && widgetMethods.includes(node.callee.property?.name);
|
return node.type === 'CallExpression' && widgetMethods.includes(node.callee.property?.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sliderWithLocation(node) {
|
function sliderWithLocation(node, nodeOffset = 0) {
|
||||||
const id = 'slider_' + node.arguments[0].start; // use loc of first arg for id
|
// Apply nodeOffset for block-based evaluation to generate correct range
|
||||||
|
const from = node.arguments[0].start + nodeOffset;
|
||||||
|
const to = node.arguments[0].end + nodeOffset;
|
||||||
|
|
||||||
|
// Use range-based ID for stability during block evaluation
|
||||||
|
const id = `${from}:${to}`;
|
||||||
|
|
||||||
// add loc as identifier to first argument
|
// add loc as identifier to first argument
|
||||||
// the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?)
|
// the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?)
|
||||||
node.arguments.unshift({
|
node.arguments.unshift({
|
||||||
|
|
@ -432,14 +516,26 @@ function sliderWithLocation(node) {
|
||||||
export function getWidgetID(widgetConfig) {
|
export function getWidgetID(widgetConfig) {
|
||||||
// the widget id is used as id for the dom element + as key for eventual resources
|
// the widget id is used as id for the dom element + as key for eventual resources
|
||||||
// for example, for each scope widget, a new analyser + buffer (large) is created
|
// for example, for each scope widget, a new analyser + buffer (large) is created
|
||||||
// that means, if we use the index index of line position as id, less garbage is generated
|
// Update: use range-based ID generation for better stability during block evaluation
|
||||||
// return `widget_${widgetConfig.to}`; // more gargabe
|
// When we have both from and to, use them together for stability
|
||||||
//return `widget_${widgetConfig.index}_${widgetConfig.to}`; // also more garbage
|
// Otherwise fall back to position-based ID for backward compatibility
|
||||||
return `${widgetConfig.id || ''}_widget_${widgetConfig.type}_${widgetConfig.index}`; // less garbage
|
let uniqueIdentifier;
|
||||||
|
if (widgetConfig.from !== undefined && widgetConfig.to !== undefined) {
|
||||||
|
// Use range for more stable identification
|
||||||
|
uniqueIdentifier = `${widgetConfig.from}-${widgetConfig.to}`;
|
||||||
|
} else {
|
||||||
|
// Fallback to single position (for backward compatibility)
|
||||||
|
uniqueIdentifier = widgetConfig.to || widgetConfig.from || 0;
|
||||||
|
}
|
||||||
|
const baseId = `${widgetConfig.id || ''}_widget_${widgetConfig.type}`;
|
||||||
|
return `${baseId}_${widgetConfig.index}_${uniqueIdentifier}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function widgetWithLocation(node, widgetConfig) {
|
function widgetWithLocation(node, widgetConfig) {
|
||||||
const id = getWidgetID(widgetConfig);
|
const id = getWidgetID(widgetConfig);
|
||||||
|
// Store the unique ID back into the config so it's available for widget management
|
||||||
|
// This is critical for block-based evaluation to match existing widgets with new ones
|
||||||
|
widgetConfig.id = id;
|
||||||
// add loc as identifier to first argument
|
// add loc as identifier to first argument
|
||||||
// the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?)
|
// the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?)
|
||||||
node.arguments.unshift({
|
node.arguments.unshift({
|
||||||
|
|
@ -454,6 +550,10 @@ function isBareSamplesCall(node, parent) {
|
||||||
return node.type === 'CallExpression' && node.callee.name === 'samples' && parent.type !== 'AwaitExpression';
|
return node.type === 'CallExpression' && node.callee.name === 'samples' && parent.type !== 'AwaitExpression';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAllCall(node) {
|
||||||
|
return node.type === 'CallExpression' && node.callee.name === 'all';
|
||||||
|
}
|
||||||
|
|
||||||
function withAwait(node) {
|
function withAwait(node) {
|
||||||
return {
|
return {
|
||||||
type: 'AwaitExpression',
|
type: 'AwaitExpression',
|
||||||
|
|
@ -554,6 +654,128 @@ function languageWithLocation(name, value, offset) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List of non-inline widgets that need cleanup
|
||||||
|
// These are Pattern.prototype methods that create persistent visualizations
|
||||||
|
// (should be repalced by a function call producing an actual list of registered widgets)
|
||||||
|
const nonInlineWidgets = ['punchcard', 'spiral', 'scope', 'pitchwheel', 'spectrum', 'pianoroll', 'wordfall'];
|
||||||
|
|
||||||
|
function isVisualizerCall(node) {
|
||||||
|
if (
|
||||||
|
node.type === 'CallExpression' &&
|
||||||
|
node.callee.type === 'MemberExpression' &&
|
||||||
|
nonInlineWidgets.includes(node.callee.property?.name)
|
||||||
|
) {
|
||||||
|
return node.callee.property.name;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findVisualizerInSubtree(node) {
|
||||||
|
if (!node || typeof node !== 'object') return null;
|
||||||
|
|
||||||
|
// Check if this node is a visualizer call
|
||||||
|
const viz = isVisualizerCall(node);
|
||||||
|
if (viz) return viz;
|
||||||
|
|
||||||
|
// Recursively search children
|
||||||
|
for (const key of Object.keys(node)) {
|
||||||
|
if (key === 'parent') continue; // Skip parent references to avoid cycles
|
||||||
|
const child = node[key];
|
||||||
|
if (Array.isArray(child)) {
|
||||||
|
for (const item of child) {
|
||||||
|
const found = findVisualizerInSubtree(item);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
} else if (child && typeof child === 'object' && child.type) {
|
||||||
|
const found = findVisualizerInSubtree(child);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates AST nodes for: userDefinedKeys.add('name'); strudelScope.name = name; globalThis.name = name;
|
||||||
|
// Used in block-based evaluation to persist variables/functions across blocks
|
||||||
|
// We add to both strudelScope (for internal lookups) and globalThis (for direct access)
|
||||||
|
// We also track the key in userDefinedKeys so clearScope() can remove it later
|
||||||
|
function createScopeAssignment(name) {
|
||||||
|
return [
|
||||||
|
// userDefinedKeys.add('name');
|
||||||
|
{
|
||||||
|
type: 'ExpressionStatement',
|
||||||
|
expression: {
|
||||||
|
type: 'CallExpression',
|
||||||
|
callee: {
|
||||||
|
type: 'MemberExpression',
|
||||||
|
object: {
|
||||||
|
type: 'Identifier',
|
||||||
|
name: 'userDefinedKeys',
|
||||||
|
},
|
||||||
|
property: {
|
||||||
|
type: 'Identifier',
|
||||||
|
name: 'add',
|
||||||
|
},
|
||||||
|
computed: false,
|
||||||
|
},
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
type: 'Literal',
|
||||||
|
value: name,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// strudelScope.name = name;
|
||||||
|
{
|
||||||
|
type: 'ExpressionStatement',
|
||||||
|
expression: {
|
||||||
|
type: 'AssignmentExpression',
|
||||||
|
operator: '=',
|
||||||
|
left: {
|
||||||
|
type: 'MemberExpression',
|
||||||
|
object: {
|
||||||
|
type: 'Identifier',
|
||||||
|
name: 'strudelScope',
|
||||||
|
},
|
||||||
|
property: {
|
||||||
|
type: 'Identifier',
|
||||||
|
name: name,
|
||||||
|
},
|
||||||
|
computed: false,
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
type: 'Identifier',
|
||||||
|
name: name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// globalThis.name = name;
|
||||||
|
{
|
||||||
|
type: 'ExpressionStatement',
|
||||||
|
expression: {
|
||||||
|
type: 'AssignmentExpression',
|
||||||
|
operator: '=',
|
||||||
|
left: {
|
||||||
|
type: 'MemberExpression',
|
||||||
|
object: {
|
||||||
|
type: 'Identifier',
|
||||||
|
name: 'globalThis',
|
||||||
|
},
|
||||||
|
property: {
|
||||||
|
type: 'Identifier',
|
||||||
|
name: name,
|
||||||
|
},
|
||||||
|
computed: false,
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
type: 'Identifier',
|
||||||
|
name: name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function findMiniDisableRanges(comments, codeEnd) {
|
function findMiniDisableRanges(comments, codeEnd) {
|
||||||
const ranges = [];
|
const ranges = [];
|
||||||
const stack = []; // used to track on/off pairs
|
const stack = []; // used to track on/off pairs
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ const skippedExamples = [
|
||||||
'accelerationX',
|
'accelerationX',
|
||||||
'defaultmidimap',
|
'defaultmidimap',
|
||||||
'midimaps',
|
'midimaps',
|
||||||
|
'clearScope',
|
||||||
'bmod',
|
'bmod',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,11 @@ import bundleAudioWorkletPlugin from 'vite-plugin-bundle-audioworklet';
|
||||||
import tailwind from '@astrojs/tailwind';
|
import tailwind from '@astrojs/tailwind';
|
||||||
import AstroPWA from '@vite-pwa/astro';
|
import AstroPWA from '@vite-pwa/astro';
|
||||||
|
|
||||||
const site = process.env.STRUDEL_SITE || `https://strudel.cc/`; // root url without a path
|
import process from 'node:process';
|
||||||
const base = process.env.STRUDEL_BASE || '/'; // base path of the strudel site
|
|
||||||
|
const site = process.env.SITE_URL || `https://strudel.cc/`; // root url without a path
|
||||||
|
const base = process.env.BASE_PATH || ''; // base path of the strudel site
|
||||||
|
|
||||||
const baseNoTrailing = base.endsWith('/') ? base.slice(0, -1) : base;
|
const baseNoTrailing = base.endsWith('/') ? base.slice(0, -1) : base;
|
||||||
|
|
||||||
// this rehype plugin fixes relative links
|
// this rehype plugin fixes relative links
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,7 @@ export function SettingsTab({ started }) {
|
||||||
isMultiCursorEnabled,
|
isMultiCursorEnabled,
|
||||||
patternAutoStart,
|
patternAutoStart,
|
||||||
includePrebakeScriptInShare,
|
includePrebakeScriptInShare,
|
||||||
|
isBlockBasedEvalEnabled,
|
||||||
} = useSettings();
|
} = useSettings();
|
||||||
const shouldAlwaysSync = isUdels();
|
const shouldAlwaysSync = isUdels();
|
||||||
const canChangeAudioDevice = AudioContext.prototype.setSinkId != null;
|
const canChangeAudioDevice = AudioContext.prototype.setSinkId != null;
|
||||||
|
|
@ -306,6 +307,11 @@ export function SettingsTab({ started }) {
|
||||||
onChange={(cbEvent) => settingsMap.setKey('isMultiCursorEnabled', cbEvent.target.checked)}
|
onChange={(cbEvent) => settingsMap.setKey('isMultiCursorEnabled', cbEvent.target.checked)}
|
||||||
value={isMultiCursorEnabled}
|
value={isMultiCursorEnabled}
|
||||||
/>
|
/>
|
||||||
|
<Checkbox
|
||||||
|
label="Enable Block-based Evaluation (EXPERIMENTAL)"
|
||||||
|
onChange={(cbEvent) => settingsMap.setKey('isBlockBasedEvalEnabled', cbEvent.target.checked)}
|
||||||
|
value={isBlockBasedEvalEnabled}
|
||||||
|
/>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Enable flashing on evaluation"
|
label="Enable flashing on evaluation"
|
||||||
onChange={(cbEvent) => settingsMap.setKey('isFlashEnabled', cbEvent.target.checked)}
|
onChange={(cbEvent) => settingsMap.setKey('isFlashEnabled', cbEvent.target.checked)}
|
||||||
|
|
|
||||||
|
|
@ -106,17 +106,19 @@ export function useReplContext() {
|
||||||
//post to iframe parent (like Udels) if it exists...
|
//post to iframe parent (like Udels) if it exists...
|
||||||
window.parent?.postMessage(code);
|
window.parent?.postMessage(code);
|
||||||
|
|
||||||
setLatestCode(code);
|
// Get the full buffer content from the editor instead of just the evaluated block
|
||||||
window.location.hash = '#' + code2hash(code);
|
const fullBufferCode = editorRef.current?.code || code;
|
||||||
setDocumentTitle(code);
|
setLatestCode(fullBufferCode);
|
||||||
|
window.location.hash = '#' + code2hash(fullBufferCode);
|
||||||
|
setDocumentTitle(fullBufferCode);
|
||||||
const viewingPatternData = getViewingPatternData();
|
const viewingPatternData = getViewingPatternData();
|
||||||
setVersionDefaultsFrom(code);
|
setVersionDefaultsFrom(fullBufferCode);
|
||||||
const data = { ...viewingPatternData, code };
|
const data = { ...viewingPatternData, code: fullBufferCode };
|
||||||
let id = data.id;
|
let id = data.id;
|
||||||
const isExamplePattern = viewingPatternData.collection !== userPattern.collection;
|
const isExamplePattern = viewingPatternData.collection !== userPattern.collection;
|
||||||
|
|
||||||
if (isExamplePattern) {
|
if (isExamplePattern) {
|
||||||
const codeHasChanged = code !== viewingPatternData.code;
|
const codeHasChanged = fullBufferCode !== viewingPatternData.code;
|
||||||
if (codeHasChanged) {
|
if (codeHasChanged) {
|
||||||
// fork example
|
// fork example
|
||||||
const newPattern = userPattern.duplicate(data);
|
const newPattern = userPattern.duplicate(data);
|
||||||
|
|
@ -168,9 +170,8 @@ export function useReplContext() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let editorSettings = {};
|
let editorSettings = {};
|
||||||
Object.keys(defaultSettings).forEach((key) => {
|
Object.keys(defaultSettings).forEach((key) => {
|
||||||
if (Object.prototype.hasOwnProperty.call(_settings, key)) {
|
// Don't use hasOwnProperty - nanostore uses proxies so values may not be own properties
|
||||||
editorSettings[key] = _settings[key];
|
editorSettings[key] = _settings[key];
|
||||||
}
|
|
||||||
});
|
});
|
||||||
editorRef.current?.updateSettings(editorSettings);
|
editorRef.current?.updateSettings(editorSettings);
|
||||||
}, [_settings]);
|
}, [_settings]);
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ export const defaultSettings = {
|
||||||
isPatternHighlightingEnabled: true,
|
isPatternHighlightingEnabled: true,
|
||||||
isTabIndentationEnabled: false,
|
isTabIndentationEnabled: false,
|
||||||
isMultiCursorEnabled: false,
|
isMultiCursorEnabled: false,
|
||||||
|
isBlockBasedEvalEnabled: false,
|
||||||
theme: 'strudelTheme',
|
theme: 'strudelTheme',
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
|
|
@ -89,6 +90,7 @@ export const $settings = computed(settingsMap, (state) => {
|
||||||
isSyncEnabled: isUdels() ? true : parseBoolean(state.isSyncEnabled),
|
isSyncEnabled: isUdels() ? true : parseBoolean(state.isSyncEnabled),
|
||||||
isTabIndentationEnabled: parseBoolean(state.isTabIndentationEnabled),
|
isTabIndentationEnabled: parseBoolean(state.isTabIndentationEnabled),
|
||||||
isMultiCursorEnabled: parseBoolean(state.isMultiCursorEnabled),
|
isMultiCursorEnabled: parseBoolean(state.isMultiCursorEnabled),
|
||||||
|
isBlockBasedEvalEnabled: parseBoolean(state.isBlockBasedEvalEnabled),
|
||||||
fontSize: Number(state.fontSize),
|
fontSize: Number(state.fontSize),
|
||||||
panelPosition: state.activeFooter !== '' && !isUdels() ? state.panelPosition : 'bottom', // <-- keep this 'bottom' where it is!
|
panelPosition: state.activeFooter !== '' && !isUdels() ? state.panelPosition : 'bottom', // <-- keep this 'bottom' where it is!
|
||||||
isPanelPinned: parseBoolean(state.isPanelPinned),
|
isPanelPinned: parseBoolean(state.isPanelPinned),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue