1import { ref, pure } from '@strudel/core';
2import { WidgetType, ViewPlugin, Decoration } from '@codemirror/view';
3import { StateEffect } from '@codemirror/state';

Global state storage for all widget types

6export let sliderValues = {};
8export class SliderWidget extends WidgetType {
9  constructor(value, min, max, from, to, step, view, id) {
10    super();
11    this.value = value;
12    this.min = min;
13    this.max = max;
14    this.from = from;
15    this.originalFrom = from;
16    this.to = to;
17    this.step = step;
18    this.view = view;
19    this.id = id || `${from}:${to}`; // Range-based ID for stability
20  }
21
22  eq(other) {
23    if (!(other instanceof SliderWidget)) {
24      return false;
25    }
26    return (
27      this.id === other.id &&
28      this.from === other.from &&
29      this.to === other.to &&
30      this.value === other.value &&
31      this.min === other.min &&
32      this.max === other.max
33    );
34  }
35
36  toDOM() {
37    let wrap = document.createElement('span');
38    wrap.setAttribute('aria-hidden', 'true');
39    wrap.className = 'cm-slider'; // inline-flex items-center
40    let slider = wrap.appendChild(document.createElement('input'));
41    slider.type = 'range';
42    slider.min = this.min;
43    slider.max = this.max;
44    slider.step = this.step ?? (this.max - this.min) / 1000;
45    slider.originalValue = this.value;
46    // to make sure the code stays in sync, let's save the original value
47    // becuase .value automatically clamps values so it'll desync with the code
48    slider.value = slider.originalValue;
49    slider.from = this.from;
50    slider.originalFrom = this.originalFrom;
51    slider.to = this.to;
52    slider.id = this.id; // Store range-based ID in DOM element
53    slider.style = 'width:64px;margin-right:4px;transform:translateY(4px)';
54    this.slider = slider;
55    slider.addEventListener('input', (e) => {
56      const next = e.target.value;
57      let insert = next;
58      //let insert = next.toFixed(2);
59      const to = slider.from + slider.originalValue.length;
60      let change = { from: slider.from, to, insert };
61      slider.originalValue = insert;
62      slider.value = insert;
63      this.view.dispatch({ changes: change });
64      const id = slider.id; // Use range-based ID
65      window.postMessage({ type: 'cm-slider', value: Number(next), id });
66    });
67    return wrap;
68  }
69
70  ignoreEvent(e) {
71    return true;
72  }
73}
74
75export const setSliderWidgets = StateEffect.define();
76
77export const setSliderWidgetsInRange = StateEffect.define();
78
79export const updateSliderWidgets = (view, widgets, range = null) => {
80  if (range) {
81    // range argument passed for block-based evaluation
82    view.dispatch({ effects: setSliderWidgetsInRange.of({ widgets, range }) });
83  } else {
84    view.dispatch({ effects: setSliderWidgets.of(widgets) });
85  }
86};
87
88function getSliders(widgetConfigs, view) {
89  return (
90    widgetConfigs
91      .filter((w) => w.type === 'slider')
92      // Deduplicate sliders that might appear multiple times (e.g., during paste operations)
93      .filter((slider, index, self) => index === self.findIndex((s) => s.from === slider.from && s.to === slider.to))
94      .sort((a, b) => a.from - b.from)
95      .map(({ from, to, value, min, max, step, id }) => {
96        return Decoration.widget({
97          widget: new SliderWidget(value, min, max, from, to, step, view, id),
98          side: 0,
99        }).range(from /* , to */);
100      })
101  );
102}
103
104export function getSliderWidgets(view) {
105  if (!view || !view.state) {
106    return [];
107  }
108
109  const sliderPluginInstance = view.plugin(sliderPlugin);
110  if (!sliderPluginInstance || !sliderPluginInstance.decorations) {
111    return [];
112  }
113
114  const sliderWidgets = [];
115
116  sliderPluginInstance.decorations.between(0, view.state.doc.length, (from, to, decoration) => {
117    if (decoration.widget instanceof SliderWidget) {
118      sliderWidgets.push({
119        type: 'slider',
120        from: decoration.widget.from,
121        to: decoration.widget.to,
122        value: decoration.widget.value,
123        min: decoration.widget.min,
124        max: decoration.widget.max,
125        step: decoration.widget.step,
126      });
127    }
128  });
129
130  return sliderWidgets;
131}
132
133export const sliderPlugin = ViewPlugin.fromClass(
134  class {
135    decorations; //: DecorationSet
136
137    constructor(view /* : EditorView */) {
138      this.decorations = Decoration.set([]);
139    }
140
141    update(update /* : ViewUpdate */) {
142      update.transactions.forEach((tr) => {
143        if (tr.docChanged) {
144          this.decorations = this.decorations.map(tr.changes);
145          const iterator = this.decorations.iter();
146          while (iterator.value) {
147            // when the widgets are moved, we need to tell the dom node the current position
148            // this is important because the updateSliderValue function has to work with the dom node
149            if (iterator.value?.widget?.slider) {
150              iterator.value.widget.slider.from = iterator.from;
151              iterator.value.widget.slider.to = iterator.to;
152            }
153            iterator.next();
154          }
155        }
156        for (let e of tr.effects) {
157          if (e.is(setSliderWidgetsInRange)) {
158            // Block-aware slider update logic
159            const { widgets, range } = e.value;
160            const [rangeStart, rangeEnd] = range;

Get existing slider widgets that should be preserved

163            const existingSliders = [];
164            this.decorations.between(0, update.view.state.doc.length, (from, to, decoration) => {
165              if (decoration.widget instanceof SliderWidget) {
166                // Preserve sliders outside the evaluation range
167                // Use strict > for rangeEnd because when code is deleted, slider positions
168                // map to the deletion boundary (rangeEnd), and those should be removed, not preserved
169                if (from < rangeStart || from > rangeEnd) {
170                  existingSliders.push({
171                    from,
172                    to,
173                    value: decoration.widget.value,
174                    min: decoration.widget.min,
175                    max: decoration.widget.max,
176                    step: decoration.widget.step,
177                    id: decoration.widget.id || `${from}:${to}`,
178                    type: 'slider',
179                  });
180                }
181              }
182            });

Merge preserved sliders with new widgets

185            const mergedWidgets = [...existingSliders, ...widgets]
186              .filter(
187                (slider, index, self) => index === self.findIndex((s) => s.type === 'slider' && s.id === slider.id),
188              )
189              .sort((a, b) => a.from - b.from);
191            this.decorations = Decoration.set(getSliders(mergedWidgets, update.view));
192          } else if (e.is(setSliderWidgets)) {
193            this.decorations = Decoration.set(getSliders(e.value, update.view));
194          }
195        }
196      });
197    }
198  },
199  {
200    decorations: (v) => v.decorations,
201  },
202);

Displays a slider widget to allow the user manipulate a value

@name slider @tags external_io, visualization @param {number} value Initial value @param {number} min Minimum value - optional, defaults to 0 @param {number} max Maximum value - optional, defaults to 1 @param {number} step Step size - optional

214export let slider = (value) => {
215  console.warn('slider will only work when the transpiler is used... passing value as is');
216  return pure(value);
217};

function transpiled from slider = (value, min, max)

219export let sliderWithID = (id, value, min, max) => {
220  sliderValues[id] = value; // sync state at eval time (code -> state)
221  return ref(() => sliderValues[id]); // use state at query time
222};

update state when sliders are moved

225if (typeof window !== 'undefined') {
226  window.addEventListener('message', (e) => {
227    if (e.data.type === 'cm-slider') {
228      if (sliderValues[e.data.id] !== undefined) {
229        // update state when slider is moved
230        sliderValues[e.data.id] = e.data.value;
231      } else {
232        console.error(`slider with id "${e.data.id}" is not registered. Only ${Object.keys(sliderValues)}`);
233      }
234    }
235  });
236}