diff --git a/packages/codemirror/block_utilities.mjs b/packages/codemirror/block_utilities.mjs new file mode 100644 index 000000000..fd3dbedcd --- /dev/null +++ b/packages/codemirror/block_utilities.mjs @@ -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; +}; diff --git a/packages/codemirror/codemirror.mjs b/packages/codemirror/codemirror.mjs index 8784d3844..6014b9b0a 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, Drawer } from '@strudel/draw'; - +import { cleanupDraw, cleanupDrawContext, 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 { 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 { isTooltipEnabled } from './tooltip.mjs'; -import { updateWidgets, widgetPlugin } from './widget.mjs'; -import { jumpToCharacter } from './labelJump.mjs'; +import { getActiveWidgets, updateWidgets, widgetPlugin } from './widget.mjs'; export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands'; @@ -64,6 +64,7 @@ export const defaultSettings = { isLineWrappingEnabled: false, isTabIndentationEnabled: false, isMultiCursorEnabled: false, + isBlockBasedEvalEnabled: false, theme: 'strudelTheme', fontFamily: 'monospace', fontSize: 18, @@ -75,7 +76,7 @@ export const codemirrorSettings = persistentAtom('codemirror-settings', defaultS }); // 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 initialSettings = Object.keys(compartments).map((key) => compartments[key].of(extensions[key](parseBooleans(settings[key]))), @@ -105,11 +106,26 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo keymap.of([ { 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', - run: () => onEvaluate?.(), + run: () => { + if (strudelMirror?.isBlockBasedEvalEnabled) { + evalBlock(strudelMirror); + return true; + } else { + return onEvaluate?.(); + } + }, }, { key: 'Ctrl-.', @@ -171,6 +187,7 @@ 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)); @@ -201,20 +218,28 @@ export class StrudelMirror { cleanupDraw(true, id); } }, - beforeEval: async () => { - 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); + } 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'); - updateWidgets(this.editor, widgets); - updateMiniLocations(this.editor, this.miniLocations); + // 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); replOptions?.afterEval?.(options); // if no painters are set (.onPaint was not called), then we only need // the present moment (for highlighting) @@ -222,8 +247,14 @@ 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, @@ -236,7 +267,9 @@ 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'; @@ -306,8 +339,9 @@ export class StrudelMirror { this.flash(); await this.repl.evaluate(this.code, autostart); } + async stop() { - this.repl.scheduler.stop(); + this.repl.stop(); } // Listen for global stop requests (e.g., from Vim :q) @@ -326,8 +360,8 @@ export class StrudelMirror { this.evaluate(); } } - flash(ms) { - flash(this.editor, ms); + flash(ms, range) { + flash(this.editor, ms, range); } highlight(haps, time) { highlightMiniLocations(this.editor, time, haps); @@ -359,6 +393,10 @@ export class StrudelMirror { setLineWrappingEnabled(enabled) { this.reconfigureExtension('isLineWrappingEnabled', enabled); } + + setBlockBasedEvalEnabled(enabled) { + this.reconfigureExtension('isBlockBasedEvalEnabled', enabled); + } setBracketMatchingEnabled(enabled) { this.reconfigureExtension('isBracketMatchingEnabled', enabled); } @@ -380,6 +418,10 @@ 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); } @@ -401,6 +443,16 @@ 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 243500bff..7b2473506 100644 --- a/packages/codemirror/flash.mjs +++ b/packages/codemirror/flash.mjs @@ -14,7 +14,8 @@ export const flashField = StateField.define({ const mark = Decoration.mark({ 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 { flash = Decoration.set([]); } @@ -29,8 +30,9 @@ export const flashField = StateField.define({ provide: (f) => EditorView.decorations.from(f), }); -export const flash = (view, ms = 200) => { - view.dispatch({ effects: setFlash.of(true) }); +export const flash = (view, ms = 200, range) => { + const flashData = range ? { range } : true; + view.dispatch({ effects: setFlash.of(flashData) }); setTimeout(() => { view.dispatch({ effects: setFlash.of(false) }); }, ms); diff --git a/packages/codemirror/highlight.mjs b/packages/codemirror/highlight.mjs index f9f977c6b..b37390ef7 100644 --- a/packages/codemirror/highlight.mjs +++ b/packages/codemirror/highlight.mjs @@ -3,8 +3,9 @@ import { Decoration, EditorView } from '@codemirror/view'; export const setMiniLocations = StateEffect.define(); export const showMiniLocations = StateEffect.define(); -export const updateMiniLocations = (view, locations) => { - view.dispatch({ effects: setMiniLocations.of(locations) }); +export const displayMiniLocations = StateEffect.define(); +export const updateMiniLocations = (view, locations, range = null) => { + view.dispatch({ effects: setMiniLocations.of({ locations, range }) }); }; export const highlightMiniLocations = (view, atTime, haps) => { view.dispatch({ effects: showMiniLocations.of({ atTime, haps }) }); @@ -21,23 +22,54 @@ const miniLocations = StateField.define({ for (let e of tr.effects) { if (e.is(setMiniLocations)) { - // 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: 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 - ); + //block-based eval case + if (e.value.range) { + const stateMiniLocations = getMiniLocationsFromDecorations(locations); - locations = Decoration.set(marks, true); // -> DecorationSet === RangeSet + 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 + } 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 + } } } @@ -75,12 +107,83 @@ const visibleMiniLocations = StateField.define({ }, }); -// // Derive the set of decorations from the miniLocations and visibleLocations -const miniLocationHighlights = EditorView.decorations.compute([miniLocations, visibleMiniLocations], (state) => { - const iterator = state.field(miniLocations).iter(); - const { haps } = state.field(visibleMiniLocations); - const builder = new RangeSetBuilder(); +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 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, @@ -89,50 +192,48 @@ const miniLocationHighlights = EditorView.decorations.compute([miniLocations, vi 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 }, - }), - ); - } - + miniLocationsArray.push({ + from, + to, + id, + }); iterator.next(); } + return miniLocationsArray; +}; - return builder.finish(); -}); +export const getMiniLocations = (state) => { + const decorations = state.field(miniLocations); + return getMiniLocationsFromDecorations(decorations); +}; -export const highlightExtension = [miniLocations, visibleMiniLocations, miniLocationHighlights]; +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) => { - on && - config && - setTimeout(() => { - updateMiniLocations(config.editor, config.miniLocations); - }, 100); - return on ? Prec.highest(highlightExtension) : []; + // 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); }; diff --git a/packages/codemirror/slider.mjs b/packages/codemirror/slider.mjs index cc224a6cc..be0bad975 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) { + constructor(value, min, max, from, to, step, view, id) { super(); this.value = value; this.min = min; @@ -16,10 +16,21 @@ 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() { - return false; + 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 + ); } toDOM() { @@ -38,6 +49,7 @@ 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) => { @@ -49,7 +61,7 @@ export class SliderWidget extends WidgetType { slider.originalValue = insert; slider.value = insert; 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 }); }); return wrap; @@ -62,19 +74,60 @@ export class SliderWidget extends WidgetType { export const setSliderWidgets = StateEffect.define(); -export const updateSliderWidgets = (view, widgets) => { - view.dispatch({ effects: setSliderWidgets.of(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) }); + } }; function getSliders(widgetConfigs, view) { - 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 */); - }); + 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; } export const sliderPlugin = ViewPlugin.fromClass( @@ -101,7 +154,42 @@ export const sliderPlugin = ViewPlugin.fromClass( } } 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)); } } @@ -132,6 +220,7 @@ 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) => { @@ -140,7 +229,7 @@ if (typeof window !== 'undefined') { // update state when slider is moved sliderValues[e.data.id] = e.data.value; } else { - console.warn(`slider with id "${e.data.id}" is not registered. Only ${Object.keys(sliderValues)}`); + console.error(`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 42d3b1512..6c40d6733 100644 --- a/packages/codemirror/widget.mjs +++ b/packages/codemirror/widget.mjs @@ -1,55 +1,111 @@ 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 { Pattern } from '@strudel/core'; -export const addWidget = StateEffect.define({ - map: ({ from, to }, change) => { - return { from: change.mapPos(from), to: change.mapPos(to) }; - }, -}); +export const setWidgets = StateEffect.define(); -export const updateWidgets = (view, widgets) => { - view.dispatch({ effects: addWidget.of(widgets) }); +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) }); + } }; -function getWidgets(widgetConfigs) { - return ( - widgetConfigs - // codemirror throws an error if we don't sort - .sort((a, b) => a.to - b.to) - .map((widgetConfig) => { +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 { return Decoration.widget({ - widget: new BlockWidget(widgetConfig), + widget: new BlockWidget(widgetConfig, view), side: 0, - block: true, - }).range(widgetConfig.to); - }) - ); + }).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 } -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), - }); - } catch (error) { - console.log('err', error); +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(); } } - } - return widgets; - }, - provide: (f) => EditorView.decorations.from(f), + 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, + }); + } + } + }); + + // 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)); + } + } + }); + } + }, + { + decorations: (v) => v.decorations, }, ); @@ -60,24 +116,116 @@ export function setWidget(id, el) { } export class BlockWidget extends WidgetType { - constructor(widgetConfig) { + constructor(widgetConfig, view) { 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() { - 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() { - const id = getWidgetID(this.widgetConfig); - const el = widgetElements[id]; - return el; + 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; } + ignoreEvent(e) { 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 export function registerWidget(type, fn) { diff --git a/packages/core/evaluate.mjs b/packages/core/evaluate.mjs index 0599d8662..bb7b46648 100644 --- a/packages/core/evaluate.mjs +++ b/packages/core/evaluate.mjs @@ -5,6 +5,34 @@ 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 bf86ba494..c1b539819 100644 --- a/packages/core/repl.mjs +++ b/packages/core/repl.mjs @@ -38,6 +38,7 @@ export function repl({ pattern: undefined, miniLocations: [], widgets: [], + sliders: [], pending: false, started: false, }; @@ -79,11 +80,132 @@ 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; }; @@ -103,7 +225,60 @@ export function repl({ }; 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 pause = () => scheduler.pause(); 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) { throw new Error('no code to evaluate'); } @@ -225,59 +400,34 @@ export function repl({ updateState({ code, pending: true }); await injectPatternMethods(); setTime(() => scheduler.now()); // TODO: refactor? - await beforeEval?.({ code }); + await beforeEval?.({ code, blockBased: false }); allTransforms = []; // reset all transforms - shouldHush && hush(); + + codeBlocks = {}; + hush(); if (mondo) { 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)) { - pattern = silence; - } + 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 }); + + afterEval?.({ code, pattern, meta, range: undefined, widgetRemoved: false }); return pattern; } catch (err) { logger(`[eval] error: ${err.message}`, 'error'); @@ -286,8 +436,121 @@ export function repl({ 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 }); - 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 = diff --git a/packages/draw/draw.mjs b/packages/draw/draw.mjs index 95f3aed50..f915e9116 100644 --- a/packages/draw/draw.mjs +++ b/packages/draw/draw.mjs @@ -78,6 +78,16 @@ 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 45f9dabd4..210d40852 100644 --- a/packages/transpiler/transpiler.mjs +++ b/packages/transpiler/transpiler.mjs @@ -19,7 +19,14 @@ export function registerLanguage(type, config) { } 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 = []; let ast = parse(input, { @@ -31,6 +38,13 @@ 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) { @@ -43,9 +57,28 @@ 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); @@ -88,22 +121,29 @@ export function transpiler(input, options = {}) { return this.replace(miniWithLocation(value, node)); } if (isSliderFunction(node)) { - emitWidgets && - widgets.push({ - from: node.arguments[0].start, - to: node.arguments[0].end, - value: node.arguments[0].raw, // don't use value! - min: node.arguments[1]?.value ?? 0, - max: node.arguments[2]?.value ?? 1, - step: node.arguments[3]?.value, - type: 'slider', - }); - return this.replace(sliderWithLocation(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)); } 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, @@ -116,8 +156,30 @@ 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) { @@ -181,17 +243,33 @@ 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({ - type: 'ExpressionStatement', - expression: { - type: 'Identifier', - name: 'silence', - }, - }); + body.push(silenceExpression); } else if (!body?.[body.length - 1]?.expression) { - throw new Error('unexpected ast format without body 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); } // add return to last statement @@ -209,7 +287,7 @@ export function transpiler(input, options = {}) { if (!emitMiniLocations) { return { output }; } - return { output, miniLocations, widgets }; + return { output, miniLocations, widgets, sliders, labels }; } function isKabelCall(node) { @@ -416,8 +494,14 @@ function isWidgetMethod(node) { return node.type === 'CallExpression' && widgetMethods.includes(node.callee.property?.name); } -function sliderWithLocation(node) { - const id = 'slider_' + node.arguments[0].start; // use loc of first arg for id +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}`; + // add loc as identifier to first argument // the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?) node.arguments.unshift({ @@ -432,14 +516,26 @@ function sliderWithLocation(node) { 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 - // 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 + // 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}`; } 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({ @@ -454,6 +550,10 @@ 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', @@ -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) { const ranges = []; const stack = []; // used to track on/off pairs diff --git a/test/examples.test.mjs b/test/examples.test.mjs index ebec2252e..5ee1348a3 100644 --- a/test/examples.test.mjs +++ b/test/examples.test.mjs @@ -20,6 +20,7 @@ const skippedExamples = [ 'accelerationX', 'defaultmidimap', 'midimaps', + 'clearScope', 'bmod', ]; diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 37904cb82..867635883 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -10,8 +10,11 @@ import bundleAudioWorkletPlugin from 'vite-plugin-bundle-audioworklet'; import tailwind from '@astrojs/tailwind'; import AstroPWA from '@vite-pwa/astro'; -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 +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 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 d29534468..62d310108 100644 --- a/website/src/repl/components/panel/SettingsTab.jsx +++ b/website/src/repl/components/panel/SettingsTab.jsx @@ -150,6 +150,7 @@ export function SettingsTab({ started }) { isMultiCursorEnabled, patternAutoStart, includePrebakeScriptInShare, + isBlockBasedEvalEnabled, } = useSettings(); const shouldAlwaysSync = isUdels(); const canChangeAudioDevice = AudioContext.prototype.setSinkId != null; @@ -306,6 +307,11 @@ 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 0fbd89e38..0d44eedfe 100644 --- a/website/src/repl/useReplContext.jsx +++ b/website/src/repl/useReplContext.jsx @@ -106,17 +106,19 @@ export function useReplContext() { //post to iframe parent (like Udels) if it exists... window.parent?.postMessage(code); - setLatestCode(code); - window.location.hash = '#' + code2hash(code); - setDocumentTitle(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); const viewingPatternData = getViewingPatternData(); - setVersionDefaultsFrom(code); - const data = { ...viewingPatternData, code }; + setVersionDefaultsFrom(fullBufferCode); + const data = { ...viewingPatternData, code: fullBufferCode }; let id = data.id; const isExamplePattern = viewingPatternData.collection !== userPattern.collection; if (isExamplePattern) { - const codeHasChanged = code !== viewingPatternData.code; + const codeHasChanged = fullBufferCode !== viewingPatternData.code; if (codeHasChanged) { // fork example const newPattern = userPattern.duplicate(data); @@ -168,9 +170,8 @@ export function useReplContext() { useEffect(() => { let editorSettings = {}; Object.keys(defaultSettings).forEach((key) => { - if (Object.prototype.hasOwnProperty.call(_settings, key)) { - editorSettings[key] = _settings[key]; - } + // Don't use hasOwnProperty - nanostore uses proxies so values may not be own properties + editorSettings[key] = _settings[key]; }); editorRef.current?.updateSettings(editorSettings); }, [_settings]); diff --git a/website/src/settings.mjs b/website/src/settings.mjs index 7c0ec25ff..6acf8e2e2 100644 --- a/website/src/settings.mjs +++ b/website/src/settings.mjs @@ -33,6 +33,7 @@ export const defaultSettings = { isPatternHighlightingEnabled: true, isTabIndentationEnabled: false, isMultiCursorEnabled: false, + isBlockBasedEvalEnabled: false, theme: 'strudelTheme', fontFamily: 'monospace', fontSize: 18, @@ -89,6 +90,7 @@ 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),