1import { StateEffect, StateField } from '@codemirror/state';
2import { Decoration, EditorView, WidgetType, ViewPlugin } from '@codemirror/view';
3import { getWidgetID, registerWidgetType } from '@strudel/transpiler';
4import { Pattern } from '@strudel/core';
5
6export const setWidgets = StateEffect.define();
7
8export const setWidgetsInRange = StateEffect.define();
9
10export const updateWidgets = (view, widgets, range = null) => {
11  if (range) {
12    // range argument passed for block-based evaluation
13    view.dispatch({ effects: setWidgetsInRange.of({ widgets, range }) });
14  } else {
15    view.dispatch({ effects: setWidgets.of(widgets) });
16  }
17};
18
19function getWidgets(widgetConfigs, view) {
20  const filtered = widgetConfigs
21    // Filter to widget configs only (exclude sliders)
22    .filter((w) => w && w.type && w.type !== 'slider')
23    // Deduplicate widgets by ID, matching slider behavior for stable widget identity
24    .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);

29  return filtered
30    .sort((a, b) => (a.to || 0) - (b.to || 0))
31    .map((widgetConfig) => {
32      try {
33        return Decoration.widget({
34          widget: new BlockWidget(widgetConfig, view),
35          side: 0,
36        }).range(widgetConfig.to || widgetConfig.from || 0);
37      } catch (error) {
38        console.error('error creating widget', error);
39        return null;
40      }
41    })
42    .filter(Boolean); // Remove any null results from failed creations
43}
45export const widgetPlugin = ViewPlugin.fromClass(
46  class {
47    decorations; //: DecorationSet
48
49    constructor(view /* : EditorView */) {
50      this.decorations = Decoration.set([]);
51    }
52
53    update(update /* : ViewUpdate */) {
54      update.transactions.forEach((tr) => {
55        if (tr.docChanged) {
56          this.decorations = this.decorations.map(tr.changes);
57          const iterator = this.decorations.iter();
58          // Apply changes to iterator.from and iterator.to if docChanged
59          while (iterator.value) {
60            // when the widgets are moved, we need to tell the dom node the current position
61            // this is important because the widget functions have to work with the dom node
62            if (iterator.value?.widget instanceof BlockWidget) {
63              iterator.value.widget.from = iterator.from;
64              iterator.value.widget.to = iterator.to;
65            }
66            iterator.next();
67          }
68        }
69        for (let e of tr.effects) {
70          if (e.is(setWidgetsInRange)) {
71            // Block-aware widget update logic
72            const { widgets, range } = e.value;
73            const [rangeStart, rangeEnd] = range;

Get existing widget widgets that should be preserved

76            const existingWidgets = [];
77            this.decorations.between(0, update.view.state.doc.length, (from, to, decoration) => {
78              if (decoration.widget instanceof BlockWidget) {
79                // Preserve widgets outside the evaluation range
80                // Use strict > for rangeEnd because when code is deleted, widget positions
81                // map to the deletion boundary (rangeEnd), and those should be removed, not preserved
82                if (from < rangeStart || from > rangeEnd) {
83                  existingWidgets.push({
84                    from: decoration.widget.from,
85                    to: decoration.widget.to,
86                    type: decoration.widget.type,
87                    index: decoration.widget.index,
88                    id: decoration.widget.id,
89                  });
90                }
91              }
92            });

Merge preserved widgets with new widgets, deduplicating by ID

95            const mergedWidgets = [...existingWidgets, ...widgets].filter(
96              (widget, index, self) => index === self.findIndex((w) => w.type === widget.type && w.id === widget.id),
97            );
99            this.decorations = Decoration.set(getWidgets(mergedWidgets, update.view));
100          } else if (e.is(setWidgets)) {
101            this.decorations = Decoration.set(getWidgets(e.value, update.view));
102          }
103        }
104      });
105    }
106  },
107  {
108    decorations: (v) => v.decorations,
109  },
110);
111
112const widgetElements = {};
113export function setWidget(id, el) {
114  widgetElements[id] = el;
115  el.id = id;
116}
117
118export class BlockWidget extends WidgetType {
119  constructor(widgetConfig, view) {
120    super();

Graceful handling of invalid configs like sliders

123    if (!widgetConfig || typeof widgetConfig !== 'object') {
124      widgetConfig = { type: 'unknown', from: 0, to: 0 };
125    }
127    this.from = widgetConfig.from || 0;
128    this.originalFrom = widgetConfig.from || 0;
129    this.to = widgetConfig.to || this.from;
130    this.originalTo = widgetConfig.to || this.from;
131    this.type = widgetConfig.type || 'unknown';
132    this.index = widgetConfig.index || 0;
133    this.view = view;

Use range-based ID for stability, similar to sliders

136    this.id = widgetConfig.id || getWidgetID?.(widgetConfig);
137    this.widgetConfig = widgetConfig;
138  }
140  eq(other) {
141    if (!(other instanceof BlockWidget)) {
142      return false;
143    }
144    return (
145      this.id === other.id &&
146      this.from === other.from &&
147      this.to === other.to &&
148      this.type === other.type &&
149      this.index === other.index
150    );
151  }
152
153  toDOM() {
154    let wrap = document.createElement('span');
155    wrap.setAttribute('aria-hidden', 'true');
156    wrap.className = 'cm-widget-container';
157
158    let el = widgetElements[this.id];
159    if (el) {
160      // Ensure the element has the correct ID
161      el.id = this.id;
162      wrap.appendChild(el);
163    } else {
164      // Create a placeholder element if the widget element doesn't exist
165      // This prevents CodeMirror errors when widget is missing
166      const placeholder = document.createElement('span');
167      placeholder.setAttribute('aria-hidden', 'true');
168      placeholder.className = 'cm-widget-placeholder';
169      placeholder.style.cssText = 'display: none;'; // Hide placeholder
170      placeholder.id = this.id;
171      wrap.appendChild(placeholder);
172    }
173
174    return wrap;
175  }
176
177  ignoreEvent(e) {
178    return true;
179  }
180}
181
182export function getActiveWidgets(view) {
183  if (!view || !view.state) {
184    return [];
185  }
186
187  const widgetPluginInstance = view.plugin(widgetPlugin);
188  if (!widgetPluginInstance || !widgetPluginInstance.decorations) {
189    return [];
190  }
191
192  const widgets = [];
193
194  widgetPluginInstance.decorations.between(0, view.state.doc.length, (from, to, decoration) => {
195    if (decoration.widget instanceof BlockWidget) {
196      widgets.push({
197        type: decoration.widget.type,
198        from: decoration.widget.from,
199        to: decoration.widget.to,
200        index: decoration.widget.index,
201        id: decoration.widget.id,
202      });
203    }
204  });
205
206  return widgets;
207}
208
209export function getAllWidgetIds(view) {
210  if (!view || !view.state) {
211    return [];
212  }
213
214  const widgetPluginInstance = view.plugin(widgetPlugin);
215  if (!widgetPluginInstance || !widgetPluginInstance.decorations) {
216    return [];
217  }
218
219  const widgetIds = [];
220
221  widgetPluginInstance.decorations.between(0, view.state.doc.length, (from, to, decoration) => {
222    if (decoration.widget instanceof BlockWidget) {
223      widgetIds.push(decoration.widget.id);
224    }
225  });
226
227  return widgetIds;
228}

widget implementer API to create a new widget type

231export function registerWidget(type, fn) {
232  registerWidgetType(type);
233  if (fn) {
234    Pattern.prototype[type] = function (id, options = { fold: 1 }) {
235      // fn is expected to create a dom element and call setWidget(id, el);
236      // fn should also return the pattern
237      return fn(id, options, this);
238    };
239  }
240}

wire up @strudel/draw functions

244function getCanvasWidget(id, options = {}) {
245  const { width = 500, height = 60, pixelRatio = window.devicePixelRatio } = options;
246  let canvas = document.getElementById(id) || document.createElement('canvas');
247  canvas.width = width * pixelRatio;
248  canvas.height = height * pixelRatio;
249  canvas.style.width = width + 'px';
250  canvas.style.height = height + 'px';
251  setWidget(id, canvas);
252  return canvas;
253}
255registerWidget('_pianoroll', (id, options = {}, pat) => {
256  const ctx = getCanvasWidget(id, options).getContext('2d');
257  return pat.tag(id).pianoroll({ fold: 1, ...options, ctx, id });
258});
259
260registerWidget('_punchcard', (id, options = {}, pat) => {
261  const ctx = getCanvasWidget(id, options).getContext('2d');
262  return pat.tag(id).punchcard({ fold: 1, ...options, ctx, id });
263});
264
265registerWidget('_spiral', (id, options = {}, pat) => {
266  let _size = options.size || 275;
267  options = { width: _size, height: _size, ...options, size: _size / 5 };
268  const ctx = getCanvasWidget(id, options).getContext('2d');
269  return pat.tag(id).spiral({ ...options, ctx, id });
270});
271
272registerWidget('_scope', (id, options = {}, pat) => {
273  options = { width: 500, height: 60, pos: 0.5, scale: 1, ...options };
274  const ctx = getCanvasWidget(id, options).getContext('2d');
275  return pat.tag(id).scope({ ...options, ctx, id });
276});
277
278registerWidget('_pitchwheel', (id, options = {}, pat) => {
279  let _size = options.size || 200;
280  options = { width: _size, height: _size, ...options, size: _size / 5 };
281  const ctx = getCanvasWidget(id, options).getContext('2d');
282  return pat.pitchwheel({ ...options, ctx, id });
283});
284
285registerWidget('_spectrum', (id, options = {}, pat) => {
286  let _size = options.size || 200;
287  options = { width: _size, height: _size, ...options, size: _size / 5 };
288  const ctx = getCanvasWidget(id, options).getContext('2d');
289  return pat.spectrum({ ...options, ctx, id });
290});