block based eval
This commit is contained in:
parent
5a51b4ec71
commit
fd1e3d248a
13 changed files with 1106 additions and 200 deletions
51
packages/codemirror/block_utilities.mjs
Normal file
51
packages/codemirror/block_utilities.mjs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// Block-based evaluation utilities
|
||||
|
||||
export function getBlockRegions(code) {
|
||||
const chars = code.split('');
|
||||
let i = 0,
|
||||
blanks = [],
|
||||
blockStart = 0,
|
||||
regions = [];
|
||||
while (i < chars.length) {
|
||||
const isBlank = chars[i] === '\n';
|
||||
if (isBlank) {
|
||||
blanks.push(i);
|
||||
} else if (chars[i].trim() !== '') {
|
||||
if (blanks.length > 1) {
|
||||
regions.push([blockStart, blanks[0]]);
|
||||
blockStart = i;
|
||||
}
|
||||
blanks = [];
|
||||
}
|
||||
i++;
|
||||
}
|
||||
regions.push([blockStart, blanks.length ? blanks[0] : i]);
|
||||
return regions;
|
||||
}
|
||||
|
||||
export function getBlockAt(code, cursor) {
|
||||
const regions = getBlockRegions(code);
|
||||
for (const [start, end] of regions) {
|
||||
if (cursor >= start && cursor <= end) {
|
||||
return [start, end];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const evalBlock = (strudelMirror) => {
|
||||
const { state } = strudelMirror.editor;
|
||||
const code = state.doc.toString();
|
||||
const cursor = state.selection.main.head;
|
||||
const range = getBlockAt(code, cursor);
|
||||
if (range) {
|
||||
const [a, b] = range;
|
||||
const block = code.slice(a, b);
|
||||
if (block) {
|
||||
// Flash the block being evaluated
|
||||
strudelMirror.flash(200, { from: a, to: b });
|
||||
strudelMirror.repl.evaluate(block, true, true, true, { range });
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
|
@ -13,7 +13,7 @@ import {
|
|||
} from '@codemirror/view';
|
||||
import { persistentAtom } from '@nanostores/persistent';
|
||||
import { logger, registerControl, repl } from '@strudel/core';
|
||||
import { cleanupDraw, Drawer } from '@strudel/draw';
|
||||
import { cleanupDraw, Drawer, cleanupDrawContext } from '@strudel/draw';
|
||||
|
||||
import { isAutoCompletionEnabled } from './autocomplete.mjs';
|
||||
import { basicSetup } from './basicSetup.mjs';
|
||||
|
|
@ -21,9 +21,13 @@ 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 { widgetPlugin, updateWidgets } from './widget.mjs';
|
||||
import { activateTheme, initTheme, theme } from './themes.mjs';
|
||||
import { isTooltipEnabled } from './tooltip.mjs';
|
||||
import { updateWidgets, widgetPlugin } from './widget.mjs';
|
||||
import { getActiveWidgets } from './widget.mjs';
|
||||
import { getSliderWidgets } from './slider.mjs';
|
||||
|
||||
import { evalBlock } from './block_utilities.mjs';
|
||||
|
||||
export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands';
|
||||
|
||||
|
|
@ -63,6 +67,7 @@ export const defaultSettings = {
|
|||
isLineWrappingEnabled: false,
|
||||
isTabIndentationEnabled: false,
|
||||
isMultiCursorEnabled: false,
|
||||
isBlockBasedEvalEnabled: false,
|
||||
theme: 'strudelTheme',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 18,
|
||||
|
|
@ -74,7 +79,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]))),
|
||||
|
|
@ -104,11 +109,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-.',
|
||||
|
|
@ -162,6 +182,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));
|
||||
|
|
@ -169,6 +190,7 @@ export class StrudelMirror {
|
|||
this.onDraw(haps, time, painters);
|
||||
}, drawTime);
|
||||
|
||||
|
||||
this.prebaked = prebake();
|
||||
autodraw && this.drawFirstFrame();
|
||||
this.repl = repl({
|
||||
|
|
@ -192,20 +214,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)
|
||||
|
|
@ -213,8 +243,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,
|
||||
|
|
@ -227,7 +263,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';
|
||||
|
|
@ -297,8 +335,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)
|
||||
|
|
@ -317,8 +356,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);
|
||||
|
|
@ -350,6 +389,10 @@ export class StrudelMirror {
|
|||
setLineWrappingEnabled(enabled) {
|
||||
this.reconfigureExtension('isLineWrappingEnabled', enabled);
|
||||
}
|
||||
|
||||
setBlockBasedEvalEnabled(enabled) {
|
||||
this.reconfigureExtension('isBlockBasedEvalEnabled', enabled);
|
||||
}
|
||||
setBracketMatchingEnabled(enabled) {
|
||||
this.reconfigureExtension('isBracketMatchingEnabled', enabled);
|
||||
}
|
||||
|
|
@ -371,6 +414,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);
|
||||
}
|
||||
|
|
@ -392,6 +439,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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,56 @@ 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);
|
||||
const existingById = new Map(stateMiniLocations.map(({ id, from, to }) => [id, [from, to]]));
|
||||
|
||||
locations = Decoration.set(marks, true); // -> DecorationSet === RangeSet<Decoration>
|
||||
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(':');
|
||||
const useRange = existingById.get(id) || range;
|
||||
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(...useRange); // -> Decoration
|
||||
});
|
||||
|
||||
const previousMarks = stateMiniLocations
|
||||
.filter(({ id }) => !newIds.has(id))
|
||||
.map(({ from, to, id }) =>
|
||||
Decoration.mark({
|
||||
id,
|
||||
attributes: { style: `background-color: #00CA2880` },
|
||||
}).range(from, to),
|
||||
);
|
||||
|
||||
locations = Decoration.set(previousMarks.concat(marks), true); // -> DecorationSet === RangeSet<Decoration>
|
||||
} else {
|
||||
// this is called on eval, with the mini locations obtained from the transpiler
|
||||
// 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<Decoration>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -75,12 +109,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 +194,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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -131,6 +219,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) => {
|
||||
|
|
@ -139,7 +228,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)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
/* <DecorationSet> */ {
|
||||
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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue