From 2ebf9f710348846e7c2ea5ff9c773674fa882e8b Mon Sep 17 00:00:00 2001 From: hirnsalat Date: Wed, 14 Oct 2026 19:02:33 +0200 Subject: [PATCH] Revert "Merge pull request 'block based eval' (#1714) from Dsm0/strudel:block-based-eval-squashed into main" This reverts commit 70be560a7a21003ffb6b9c8d6e126480dd6902ef, reversing changes made to 98d613fbdfa769e8dcd42c804aaec8fbf41ea5b7. --- packages/codemirror/block_utilities.mjs | 51 --- packages/codemirror/codemirror.mjs | 86 +---- packages/codemirror/flash.mjs | 8 +- packages/codemirror/highlight.mjs | 221 ++++-------- packages/codemirror/slider.mjs | 123 +------ packages/codemirror/widget.mjs | 238 +++---------- packages/core/evaluate.mjs | 28 -- packages/core/repl.mjs | 333 ++---------------- packages/draw/draw.mjs | 10 - packages/transpiler/transpiler.mjs | 276 ++------------- test/examples.test.mjs | 1 - website/astro.config.mjs | 7 +- .../src/repl/components/panel/SettingsTab.jsx | 6 - website/src/repl/useReplContext.jsx | 19 +- website/src/settings.mjs | 2 - 15 files changed, 215 insertions(+), 1194 deletions(-) delete mode 100644 packages/codemirror/block_utilities.mjs diff --git a/packages/codemirror/block_utilities.mjs b/packages/codemirror/block_utilities.mjs deleted file mode 100644 index fd3dbedcd..000000000 --- a/packages/codemirror/block_utilities.mjs +++ /dev/null @@ -1,51 +0,0 @@ -// 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; -}; diff --git a/packages/codemirror/codemirror.mjs b/packages/codemirror/codemirror.mjs index 6014b9b0a..8784d3844 100644 --- a/packages/codemirror/codemirror.mjs +++ b/packages/codemirror/codemirror.mjs @@ -13,18 +13,18 @@ import { } from '@codemirror/view'; import { persistentAtom } from '@nanostores/persistent'; import { logger, registerControl, repl } from '@strudel/core'; -import { cleanupDraw, cleanupDrawContext, Drawer } from '@strudel/draw'; +import { cleanupDraw, Drawer } from '@strudel/draw'; + import { isAutoCompletionEnabled } from './autocomplete.mjs'; import { basicSetup } from './basicSetup.mjs'; -import { evalBlock } from './block_utilities.mjs'; import { flash, isFlashEnabled } from './flash.mjs'; import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs'; import { keybindings } from './keybindings.mjs'; -import { jumpToCharacter } from './labelJump.mjs'; -import { getSliderWidgets, sliderPlugin, updateSliderWidgets } from './slider.mjs'; +import { sliderPlugin, updateSliderWidgets } from './slider.mjs'; import { activateTheme, initTheme, theme } from './themes.mjs'; import { isTooltipEnabled } from './tooltip.mjs'; -import { getActiveWidgets, updateWidgets, widgetPlugin } from './widget.mjs'; +import { updateWidgets, widgetPlugin } from './widget.mjs'; +import { jumpToCharacter } from './labelJump.mjs'; export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands'; @@ -64,7 +64,6 @@ export const defaultSettings = { isLineWrappingEnabled: false, isTabIndentationEnabled: false, isMultiCursorEnabled: false, - isBlockBasedEvalEnabled: false, theme: 'strudelTheme', fontFamily: 'monospace', fontSize: 18, @@ -76,7 +75,7 @@ export const codemirrorSettings = persistentAtom('codemirror-settings', defaultS }); // https://codemirror.net/docs/guide/ -export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, root, mondo, strudelMirror }) { +export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, root, mondo }) { const settings = codemirrorSettings.get(); const initialSettings = Object.keys(compartments).map((key) => compartments[key].of(extensions[key](parseBooleans(settings[key]))), @@ -106,26 +105,11 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo keymap.of([ { key: 'Ctrl-Enter', - run: () => { - // issue with referencing settings, this works more reliably - if (strudelMirror?.isBlockBasedEvalEnabled) { - evalBlock(strudelMirror); - return true; - } else { - return onEvaluate?.(); - } - }, + run: () => onEvaluate?.(), }, { key: 'Alt-Enter', - run: () => { - if (strudelMirror?.isBlockBasedEvalEnabled) { - evalBlock(strudelMirror); - return true; - } else { - return onEvaluate?.(); - } - }, + run: () => onEvaluate?.(), }, { key: 'Ctrl-.', @@ -187,7 +171,6 @@ export class StrudelMirror { this.onDraw = onDraw || this.draw; this.id = id || s4(); this.solo = solo; - this.isBlockBasedEvalEnabled = false; // Will be updated via updateSettings() this.drawer = new Drawer((haps, time, _, painters) => { const currentFrame = haps.filter((hap) => hap.isActive(time)); @@ -218,28 +201,20 @@ export class StrudelMirror { cleanupDraw(true, id); } }, - 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); - } + beforeEval: async () => { + cleanupDraw(true, id); await this.prebaked; await replOptions?.beforeEval?.(); }, afterEval: (options) => { // remember for when highlighting is toggled on - this.miniLocations = options.meta?.miniLocations || []; + this.miniLocations = options.meta?.miniLocations; this.widgets = options.meta?.widgets; - const sliders = this.widgets.filter((w) => w.type === 'slider'); + updateSliderWidgets(this.editor, sliders); const widgets = this.widgets.filter((w) => w.type !== 'slider'); - // range-aware update for block-based evaluation - 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); + updateWidgets(this.editor, widgets); + updateMiniLocations(this.editor, this.miniLocations); replOptions?.afterEval?.(options); // if no painters are set (.onPaint was not called), then we only need // the present moment (for highlighting) @@ -247,14 +222,8 @@ export class StrudelMirror { this.drawer.setDrawTime(drawTime); // invalidate drawer after we've set the appropriate drawTime 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({ root, initialCode, @@ -267,9 +236,7 @@ export class StrudelMirror { onEvaluate: () => this.evaluate(), onStop: () => this.stop(), mondo: replOptions.mondo, - strudelMirror: this, }); - const cmEditor = this.root.querySelector('.cm-editor'); if (cmEditor) { this.root.style.display = 'block'; @@ -339,9 +306,8 @@ export class StrudelMirror { this.flash(); await this.repl.evaluate(this.code, autostart); } - async stop() { - this.repl.stop(); + this.repl.scheduler.stop(); } // Listen for global stop requests (e.g., from Vim :q) @@ -360,8 +326,8 @@ export class StrudelMirror { this.evaluate(); } } - flash(ms, range) { - flash(this.editor, ms, range); + flash(ms) { + flash(this.editor, ms); } highlight(haps, time) { highlightMiniLocations(this.editor, time, haps); @@ -393,10 +359,6 @@ export class StrudelMirror { setLineWrappingEnabled(enabled) { this.reconfigureExtension('isLineWrappingEnabled', enabled); } - - setBlockBasedEvalEnabled(enabled) { - this.reconfigureExtension('isBlockBasedEvalEnabled', enabled); - } setBracketMatchingEnabled(enabled) { this.reconfigureExtension('isBracketMatchingEnabled', enabled); } @@ -418,10 +380,6 @@ export class StrudelMirror { for (let key in extensions) { 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 }; codemirrorSettings.set(updated); } @@ -443,16 +401,6 @@ export class StrudelMirror { }; 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() { this.onStartRepl && document.removeEventListener('start-repl', this.onStartRepl); this.onEvaluateRequest && document.removeEventListener('repl-evaluate', this.onEvaluateRequest); diff --git a/packages/codemirror/flash.mjs b/packages/codemirror/flash.mjs index 7b2473506..243500bff 100644 --- a/packages/codemirror/flash.mjs +++ b/packages/codemirror/flash.mjs @@ -14,8 +14,7 @@ export const flashField = StateField.define({ const mark = Decoration.mark({ attributes: { style: `background-color: rgba(255,255,255, .4); filter: invert(10%)` }, }); - const range = e.value.range || { from: 0, to: tr.newDoc.length }; - flash = Decoration.set([mark.range(range.from, range.to)]); + flash = Decoration.set([mark.range(0, tr.newDoc.length)]); } else { flash = Decoration.set([]); } @@ -30,9 +29,8 @@ export const flashField = StateField.define({ provide: (f) => EditorView.decorations.from(f), }); -export const flash = (view, ms = 200, range) => { - const flashData = range ? { range } : true; - view.dispatch({ effects: setFlash.of(flashData) }); +export const flash = (view, ms = 200) => { + view.dispatch({ effects: setFlash.of(true) }); setTimeout(() => { view.dispatch({ effects: setFlash.of(false) }); }, ms); diff --git a/packages/codemirror/highlight.mjs b/packages/codemirror/highlight.mjs index b37390ef7..f9f977c6b 100644 --- a/packages/codemirror/highlight.mjs +++ b/packages/codemirror/highlight.mjs @@ -3,9 +3,8 @@ import { Decoration, EditorView } from '@codemirror/view'; export const setMiniLocations = StateEffect.define(); export const showMiniLocations = StateEffect.define(); -export const displayMiniLocations = StateEffect.define(); -export const updateMiniLocations = (view, locations, range = null) => { - view.dispatch({ effects: setMiniLocations.of({ locations, range }) }); +export const updateMiniLocations = (view, locations) => { + view.dispatch({ effects: setMiniLocations.of(locations) }); }; export const highlightMiniLocations = (view, atTime, haps) => { view.dispatch({ effects: showMiniLocations.of({ atTime, haps }) }); @@ -22,54 +21,23 @@ const miniLocations = StateField.define({ for (let e of tr.effects) { 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 }) => + // this is called on eval, with the mini locations obtained from the transpiler + // 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 + const marks = e.value + .filter(([from]) => from < tr.newDoc.length) + .map(([from, to]) => [from, Math.min(to, tr.newDoc.length)]) + .map( + (range) => Decoration.mark({ - id, + id: range.join(':'), + // 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(from, to), - ); + }).range(...range), // -> Decoration + ); - locations = Decoration.set(previousMarks.concat(marks), true); // -> DecorationSet === RangeSet - } else { - // this is called on eval, with the mini locations obtained from the transpiler - // 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 - const marks = e.value.locations - .filter(([from]) => from < tr.newDoc.length) - .map(([from, to]) => [from, Math.min(to, tr.newDoc.length)]) - .map( - (range) => - Decoration.mark({ - id: range.join(':'), - // 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 - ); - locations = Decoration.set(marks, true); // -> DecorationSet === RangeSet - } + locations = Decoration.set(marks, true); // -> DecorationSet === RangeSet } } @@ -107,83 +75,12 @@ 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 -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 miniLocationHighlights = EditorView.decorations.compute([miniLocations, visibleMiniLocations], (state) => { + const iterator = state.field(miniLocations).iter(); + const { haps } = state.field(visibleMiniLocations); + const builder = new RangeSetBuilder(); - const iterator = state.field(miniLocations).iter(); - const { haps } = state.field(visibleMiniLocations); - const builder = new RangeSetBuilder(); - - while (iterator.value) { - const { - from, - to, - value: { - spec: { id }, - }, - } = iterator; - - if (haps.has(id)) { - const hap = haps.get(id); - const color = hap.value?.color ?? 'var(--foreground)'; - const style = hap.value?.markcss || `outline: solid 2px ${color}`; - // Get explicit channels for color values - /* - const swatch = document.createElement('div'); - swatch.style.color = color; - document.body.appendChild(swatch); - let channels = getComputedStyle(swatch) - .color.match(/^rgba?\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})(?:,\s*(\d*(?:\.\d+)?))?\)$/) - .slice(1) - .map((c) => parseFloat(c || 1)); - document.body.removeChild(swatch); - - // Get percentage of event - const percent = 1 - (atTime - hap.whole.begin) / hap.whole.duration; - channels[3] *= percent; - */ - - builder.add( - from, - to, - Decoration.mark({ - // attributes: { style: `outline: solid 2px rgba(${channels.join(', ')})` }, - attributes: { style }, - }), - ); - } - - iterator.next(); - } - - return builder.finish(); - }, -); - -const getMiniLocationsFromDecorations = (decorations) => { - const iterator = decorations.iter(); - const miniLocationsArray = []; while (iterator.value) { const { from, @@ -192,48 +89,50 @@ const getMiniLocationsFromDecorations = (decorations) => { spec: { id }, }, } = iterator; - miniLocationsArray.push({ - from, - to, - id, - }); + + if (haps.has(id)) { + const hap = haps.get(id); + const color = hap.value?.color ?? 'var(--foreground)'; + const style = hap.value?.markcss || `outline: solid 2px ${color}`; + // Get explicit channels for color values + /* + const swatch = document.createElement('div'); + swatch.style.color = color; + document.body.appendChild(swatch); + let channels = getComputedStyle(swatch) + .color.match(/^rgba?\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})(?:,\s*(\d*(?:\.\d+)?))?\)$/) + .slice(1) + .map((c) => parseFloat(c || 1)); + document.body.removeChild(swatch); + + // Get percentage of event + const percent = 1 - (atTime - hap.whole.begin) / hap.whole.duration; + channels[3] *= percent; + */ + + builder.add( + from, + to, + Decoration.mark({ + // attributes: { style: `outline: solid 2px rgba(${channels.join(', ')})` }, + attributes: { style }, + }), + ); + } + iterator.next(); } - return miniLocationsArray; -}; -export const getMiniLocations = (state) => { - const decorations = state.field(miniLocations); - return getMiniLocationsFromDecorations(decorations); -}; + return builder.finish(); +}); -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 highlightExtension = [miniLocations, visibleMiniLocations, miniLocationHighlights]; export const isPatternHighlightingEnabled = (on, config) => { - // NOTE: - // Modified this function to always return the highlightExtension, and instead just toggle whether or not the miniLocations are displayed. - // This is because block based evaluation only updates regions of miniLocations, and those updates need to be kept track of constantly. - // The setTimeout was also removed because it conflicted with the range-specific updates required by - // block based evaluation. - // 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); + on && + config && + setTimeout(() => { + updateMiniLocations(config.editor, config.miniLocations); + }, 100); + return on ? Prec.highest(highlightExtension) : []; }; diff --git a/packages/codemirror/slider.mjs b/packages/codemirror/slider.mjs index be0bad975..cc224a6cc 100644 --- a/packages/codemirror/slider.mjs +++ b/packages/codemirror/slider.mjs @@ -2,11 +2,11 @@ import { ref, pure } from '@strudel/core'; import { WidgetType, ViewPlugin, Decoration } from '@codemirror/view'; import { StateEffect } from '@codemirror/state'; -// Global state storage for all widget types export let sliderValues = {}; +const getSliderID = (from) => `slider_${from}`; export class SliderWidget extends WidgetType { - constructor(value, min, max, from, to, step, view, id) { + constructor(value, min, max, from, to, step, view) { super(); this.value = value; this.min = min; @@ -16,21 +16,10 @@ export class SliderWidget extends WidgetType { this.to = to; this.step = step; this.view = view; - this.id = id || `${from}:${to}`; // Range-based ID for stability } - eq(other) { - if (!(other instanceof SliderWidget)) { - 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 - ); + eq() { + return false; } toDOM() { @@ -49,7 +38,6 @@ export class SliderWidget extends WidgetType { slider.from = this.from; slider.originalFrom = this.originalFrom; 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)'; this.slider = slider; slider.addEventListener('input', (e) => { @@ -61,7 +49,7 @@ export class SliderWidget extends WidgetType { slider.originalValue = insert; slider.value = insert; this.view.dispatch({ changes: change }); - const id = slider.id; // Use range-based ID + const id = getSliderID(slider.originalFrom); // matches id generated in transpiler window.postMessage({ type: 'cm-slider', value: Number(next), id }); }); return wrap; @@ -74,60 +62,19 @@ export class SliderWidget extends WidgetType { export const setSliderWidgets = StateEffect.define(); -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) }); - } +export const updateSliderWidgets = (view, widgets) => { + view.dispatch({ effects: setSliderWidgets.of(widgets) }); }; function getSliders(widgetConfigs, view) { - return ( - widgetConfigs - .filter((w) => w.type === 'slider') - // 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({ - widget: new SliderWidget(value, min, max, from, to, step, view, id), - side: 0, - }).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; + return widgetConfigs + .filter((w) => w.type === 'slider') + .map(({ from, to, value, min, max, step }) => { + return Decoration.widget({ + widget: new SliderWidget(value, min, max, from, to, step, view), + side: 0, + }).range(from /* , to */); + }); } export const sliderPlugin = ViewPlugin.fromClass( @@ -154,42 +101,7 @@ export const sliderPlugin = ViewPlugin.fromClass( } } for (let e of tr.effects) { - 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)) { + if (e.is(setSliderWidgets)) { this.decorations = Decoration.set(getSliders(e.value, update.view)); } } @@ -220,7 +132,6 @@ 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) => { @@ -229,7 +140,7 @@ if (typeof window !== 'undefined') { // update state when slider is moved sliderValues[e.data.id] = e.data.value; } else { - console.error(`slider with id "${e.data.id}" is not registered. Only ${Object.keys(sliderValues)}`); + console.warn(`slider with id "${e.data.id}" is not registered. Only ${Object.keys(sliderValues)}`); } } }); diff --git a/packages/codemirror/widget.mjs b/packages/codemirror/widget.mjs index 6c40d6733..42d3b1512 100644 --- a/packages/codemirror/widget.mjs +++ b/packages/codemirror/widget.mjs @@ -1,111 +1,55 @@ import { StateEffect, StateField } from '@codemirror/state'; -import { Decoration, EditorView, WidgetType, ViewPlugin } from '@codemirror/view'; +import { Decoration, EditorView, WidgetType } from '@codemirror/view'; import { getWidgetID, registerWidgetType } from '@strudel/transpiler'; import { Pattern } from '@strudel/core'; -export const setWidgets = StateEffect.define(); +export const addWidget = StateEffect.define({ + map: ({ from, to }, change) => { + return { from: change.mapPos(from), to: change.mapPos(to) }; + }, +}); -export const setWidgetsInRange = StateEffect.define(); - -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) }); - } +export const updateWidgets = (view, widgets) => { + view.dispatch({ effects: addWidget.of(widgets) }); }; -function getWidgets(widgetConfigs, view) { - const filtered = widgetConfigs - // Filter to widget configs only (exclude sliders) - .filter((w) => w && w.type && w.type !== 'slider') - // 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) => { - try { +function getWidgets(widgetConfigs) { + return ( + widgetConfigs + // codemirror throws an error if we don't sort + .sort((a, b) => a.to - b.to) + .map((widgetConfig) => { return Decoration.widget({ - widget: new BlockWidget(widgetConfig, view), + widget: new BlockWidget(widgetConfig), side: 0, - }).range(widgetConfig.to || widgetConfig.from || 0); - } catch (error) { - console.error('error creating widget', error); - return null; - } - }) - .filter(Boolean); // Remove any null results from failed creations + block: true, + }).range(widgetConfig.to); + }) + ); } -export const widgetPlugin = 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(); - // 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) { - if (e.is(setWidgetsInRange)) { - // Block-aware widget update logic - const { widgets, range } = e.value; - const [rangeStart, rangeEnd] = range; - - // 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, - }); - } - } +const widgetField = StateField.define( + /* */ { + create() { + return Decoration.none; + }, + update(widgets, tr) { + widgets = widgets.map(tr.changes); + for (let e of tr.effects) { + if (e.is(addWidget)) { + try { + widgets = widgets.update({ + filter: () => false, + add: getWidgets(e.value), }); - - // 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)); + } catch (error) { + console.log('err', error); } } - }); - } - }, - { - decorations: (v) => v.decorations, + } + return widgets; + }, + provide: (f) => EditorView.decorations.from(f), }, ); @@ -116,116 +60,24 @@ export function setWidget(id, el) { } export class BlockWidget extends WidgetType { - constructor(widgetConfig, view) { + constructor(widgetConfig) { 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; } - - 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 - ); + eq() { + return true; } - toDOM() { - let wrap = document.createElement('span'); - wrap.setAttribute('aria-hidden', 'true'); - 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; + const id = getWidgetID(this.widgetConfig); + const el = widgetElements[id]; + return el; } - ignoreEvent(e) { return true; } } -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; -} +export const widgetPlugin = [widgetField]; // widget implementer API to create a new widget type export function registerWidget(type, fn) { diff --git a/packages/core/evaluate.mjs b/packages/core/evaluate.mjs index bb7b46648..0599d8662 100644 --- a/packages/core/evaluate.mjs +++ b/packages/core/evaluate.mjs @@ -5,34 +5,6 @@ This program is free software: you can redistribute it and/or modify it under th */ 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) => { const results = await Promise.allSettled(args); diff --git a/packages/core/repl.mjs b/packages/core/repl.mjs index c1b539819..bf86ba494 100644 --- a/packages/core/repl.mjs +++ b/packages/core/repl.mjs @@ -38,7 +38,6 @@ export function repl({ pattern: undefined, miniLocations: [], widgets: [], - sliders: [], pending: false, started: false, }; @@ -80,132 +79,11 @@ export function repl({ let allTransform; 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 () { pPatterns = {}; anonymousIndex = 0; allTransform = undefined; eachTransform = undefined; - codeBlocks = {}; - blockPatterns.clear(); - lastActiveVisualizerLabel = null; // Reset 'all' visualizer tracking return silence; }; @@ -225,60 +103,7 @@ export function repl({ }; setTime(() => scheduler.now()); // TODO: refactor? - // 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 stop = () => scheduler.stop(); const start = () => scheduler.start(); const pause = () => scheduler.pause(); const toggle = () => scheduler.toggle(); @@ -392,7 +217,7 @@ export function repl({ }); }; - const evaluate = async (code, autostart = true) => { + const evaluate = async (code, autostart = true, shouldHush = true) => { if (!code) { throw new Error('no code to evaluate'); } @@ -400,146 +225,59 @@ export function repl({ updateState({ code, pending: true }); await injectPatternMethods(); setTime(() => scheduler.now()); // TODO: refactor? - await beforeEval?.({ code, blockBased: false }); + await beforeEval?.({ code }); allTransforms = []; // reset all transforms - - codeBlocks = {}; - hush(); + shouldHush && hush(); if (mondo) { code = `mondolang\`${code}\``; } - let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptions); - - 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: undefined, widgetRemoved: false }); - return pattern; - } catch (err) { - logger(`[eval] error: ${err.message}`, 'error'); - console.error(err); - updateState({ evalError: err, pending: false }); - 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; + 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); + } } - pPatterns = Object.fromEntries( - Object.entries(pPatterns).filter(([key]) => { - return Object.keys(codeBlocks).includes(key); - }), - ); - - pattern = applyPatternTransforms(pattern); - + if (!isPattern(pattern)) { + pattern = silence; + } 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 }); + afterEval?.({ code, pattern, meta }); return pattern; } catch (err) { logger(`[eval] error: ${err.message}`, 'error'); @@ -548,9 +286,8 @@ export function repl({ onEvalError?.(err); } }; - const setCode = (code) => updateState({ code }); - return { scheduler, evaluate, evaluateBlock, start, stop, pause, setCps, setPattern, setCode, toggle, state }; + return { scheduler, evaluate, start, stop, pause, setCps, setPattern, setCode, toggle, state }; } export const getTrigger = diff --git a/packages/draw/draw.mjs b/packages/draw/draw.mjs index f915e9116..95f3aed50 100644 --- a/packages/draw/draw.mjs +++ b/packages/draw/draw.mjs @@ -78,16 +78,6 @@ export const cleanupDraw = (clearScreen = true, 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) { return this.withState((state) => { if (!state.controls.painters) { diff --git a/packages/transpiler/transpiler.mjs b/packages/transpiler/transpiler.mjs index 210d40852..45f9dabd4 100644 --- a/packages/transpiler/transpiler.mjs +++ b/packages/transpiler/transpiler.mjs @@ -19,14 +19,7 @@ export function registerLanguage(type, config) { } export function transpiler(input, options = {}) { - const { - wrapAsync = false, - addReturn = true, - emitMiniLocations = true, - emitWidgets = true, - blockBased = false, - range = [], - } = options; + const { wrapAsync = false, addReturn = true, emitMiniLocations = true, emitWidgets = true } = options; const comments = []; let ast = parse(input, { @@ -38,13 +31,6 @@ export function transpiler(input, options = {}) { const miniDisableRanges = findMiniDisableRanges(comments, input.length); 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 minilang = languages.get('minilang'); if (minilang) { @@ -57,28 +43,9 @@ export function transpiler(input, options = {}) { } }; let widgets = []; - let sliders = []; - let labels = []; walk(ast, { 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)) { const { name } = node.tag; const language = languages.get(name); @@ -121,29 +88,22 @@ export function transpiler(input, options = {}) { return this.replace(miniWithLocation(value, node)); } if (isSliderFunction(node)) { - const from = node.arguments[0].start + nodeOffset; - const to = node.arguments[0].end + nodeOffset; - const id = `${from}:${to}`; // Range-based ID for stability - - const sliderConfig = { - from, - to, - id, - value: node.arguments[0].raw, // don't use value! - min: node.arguments[1]?.value ?? 0, - max: node.arguments[2]?.value ?? 1, - step: node.arguments[3]?.value, - type: 'slider', - }; - emitWidgets && widgets.push(sliderConfig); - sliders.push(sliderConfig); - return this.replace(sliderWithLocation(node, nodeOffset)); + 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, + step: node.arguments[3]?.value, + type: 'slider', + }); + return this.replace(sliderWithLocation(node)); } if (isWidgetMethod(node)) { const type = node.callee.property.name; const index = widgets.filter((w) => w.type === type).length; const widgetConfig = { - from: node.start, to: node.end, index, type, @@ -156,30 +116,8 @@ export function transpiler(input, options = {}) { return this.replace(withAwait(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)); } - // 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) { @@ -243,33 +181,17 @@ export function transpiler(input, options = {}) { let { body } = ast; - const silenceExpression = { - type: 'ExpressionStatement', - expression: { - type: 'Identifier', - name: 'silence', - }, - }; - if (!body.length) { console.warn('empty body -> fallback to silence'); - body.push(silenceExpression); + body.push({ + type: 'ExpressionStatement', + expression: { + type: 'Identifier', + name: 'silence', + }, + }); } 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'); - } - } - - // 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); + throw new Error('unexpected ast format without body expression'); } // add return to last statement @@ -287,7 +209,7 @@ export function transpiler(input, options = {}) { if (!emitMiniLocations) { return { output }; } - return { output, miniLocations, widgets, sliders, labels }; + return { output, miniLocations, widgets }; } function isKabelCall(node) { @@ -494,14 +416,8 @@ function isWidgetMethod(node) { return node.type === 'CallExpression' && widgetMethods.includes(node.callee.property?.name); } -function sliderWithLocation(node, nodeOffset = 0) { - // 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}`; - +function sliderWithLocation(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({ @@ -516,26 +432,14 @@ function sliderWithLocation(node, nodeOffset = 0) { export function getWidgetID(widgetConfig) { // 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 - // Update: use range-based ID generation for better stability during block evaluation - // When we have both from and to, use them together for stability - // Otherwise fall back to position-based ID for backward compatibility - 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}`; + // that means, if we use the index index of line position as id, less garbage is generated + // return `widget_${widgetConfig.to}`; // more gargabe + //return `widget_${widgetConfig.index}_${widgetConfig.to}`; // also more garbage + return `${widgetConfig.id || ''}_widget_${widgetConfig.type}_${widgetConfig.index}`; // less garbage } function widgetWithLocation(node, 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 // the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?) node.arguments.unshift({ @@ -550,10 +454,6 @@ function isBareSamplesCall(node, parent) { 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) { return { type: 'AwaitExpression', @@ -654,128 +554,6 @@ 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) { const ranges = []; const stack = []; // used to track on/off pairs diff --git a/test/examples.test.mjs b/test/examples.test.mjs index 5ee1348a3..ebec2252e 100644 --- a/test/examples.test.mjs +++ b/test/examples.test.mjs @@ -20,7 +20,6 @@ const skippedExamples = [ 'accelerationX', 'defaultmidimap', 'midimaps', - 'clearScope', 'bmod', ]; diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 867635883..37904cb82 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -10,11 +10,8 @@ import bundleAudioWorkletPlugin from 'vite-plugin-bundle-audioworklet'; import tailwind from '@astrojs/tailwind'; import AstroPWA from '@vite-pwa/astro'; -import process from 'node:process'; - -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 site = process.env.STRUDEL_SITE || `https://strudel.cc/`; // root url without a path +const base = process.env.STRUDEL_BASE || '/'; // base path of the strudel site const baseNoTrailing = base.endsWith('/') ? base.slice(0, -1) : base; // this rehype plugin fixes relative links diff --git a/website/src/repl/components/panel/SettingsTab.jsx b/website/src/repl/components/panel/SettingsTab.jsx index 62d310108..d29534468 100644 --- a/website/src/repl/components/panel/SettingsTab.jsx +++ b/website/src/repl/components/panel/SettingsTab.jsx @@ -150,7 +150,6 @@ export function SettingsTab({ started }) { isMultiCursorEnabled, patternAutoStart, includePrebakeScriptInShare, - isBlockBasedEvalEnabled, } = useSettings(); const shouldAlwaysSync = isUdels(); const canChangeAudioDevice = AudioContext.prototype.setSinkId != null; @@ -307,11 +306,6 @@ export function SettingsTab({ started }) { onChange={(cbEvent) => settingsMap.setKey('isMultiCursorEnabled', cbEvent.target.checked)} value={isMultiCursorEnabled} /> - settingsMap.setKey('isBlockBasedEvalEnabled', cbEvent.target.checked)} - value={isBlockBasedEvalEnabled} - /> settingsMap.setKey('isFlashEnabled', cbEvent.target.checked)} diff --git a/website/src/repl/useReplContext.jsx b/website/src/repl/useReplContext.jsx index 0d44eedfe..0fbd89e38 100644 --- a/website/src/repl/useReplContext.jsx +++ b/website/src/repl/useReplContext.jsx @@ -106,19 +106,17 @@ export function useReplContext() { //post to iframe parent (like Udels) if it exists... window.parent?.postMessage(code); - // Get the full buffer content from the editor instead of just the evaluated block - const fullBufferCode = editorRef.current?.code || code; - setLatestCode(fullBufferCode); - window.location.hash = '#' + code2hash(fullBufferCode); - setDocumentTitle(fullBufferCode); + setLatestCode(code); + window.location.hash = '#' + code2hash(code); + setDocumentTitle(code); const viewingPatternData = getViewingPatternData(); - setVersionDefaultsFrom(fullBufferCode); - const data = { ...viewingPatternData, code: fullBufferCode }; + setVersionDefaultsFrom(code); + const data = { ...viewingPatternData, code }; let id = data.id; const isExamplePattern = viewingPatternData.collection !== userPattern.collection; if (isExamplePattern) { - const codeHasChanged = fullBufferCode !== viewingPatternData.code; + const codeHasChanged = code !== viewingPatternData.code; if (codeHasChanged) { // fork example const newPattern = userPattern.duplicate(data); @@ -170,8 +168,9 @@ export function useReplContext() { useEffect(() => { let editorSettings = {}; Object.keys(defaultSettings).forEach((key) => { - // Don't use hasOwnProperty - nanostore uses proxies so values may not be own properties - editorSettings[key] = _settings[key]; + if (Object.prototype.hasOwnProperty.call(_settings, key)) { + editorSettings[key] = _settings[key]; + } }); editorRef.current?.updateSettings(editorSettings); }, [_settings]); diff --git a/website/src/settings.mjs b/website/src/settings.mjs index 6acf8e2e2..7c0ec25ff 100644 --- a/website/src/settings.mjs +++ b/website/src/settings.mjs @@ -33,7 +33,6 @@ export const defaultSettings = { isPatternHighlightingEnabled: true, isTabIndentationEnabled: false, isMultiCursorEnabled: false, - isBlockBasedEvalEnabled: false, theme: 'strudelTheme', fontFamily: 'monospace', fontSize: 18, @@ -90,7 +89,6 @@ export const $settings = computed(settingsMap, (state) => { isSyncEnabled: isUdels() ? true : parseBoolean(state.isSyncEnabled), isTabIndentationEnabled: parseBoolean(state.isTabIndentationEnabled), isMultiCursorEnabled: parseBoolean(state.isMultiCursorEnabled), - isBlockBasedEvalEnabled: parseBoolean(state.isBlockBasedEvalEnabled), fontSize: Number(state.fontSize), panelPosition: state.activeFooter !== '' && !isUdels() ? state.panelPosition : 'bottom', // <-- keep this 'bottom' where it is! isPanelPinned: parseBoolean(state.isPanelPinned),