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)); 25 26 // Filter out widgets whose range is encompassed by another widget 27 // const nonEncompassed = filterEncompassedWidgets(filtered); 28 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} 44 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; 74 75 // 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 }); 93 94 // 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 ); 98 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(); 121 122 // Graceful handling of invalid configs like sliders 123 if (!widgetConfig || typeof widgetConfig !== 'object') { 124 widgetConfig = { type: 'unknown', from: 0, to: 0 }; 125 } 126 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; 134 135 // Use range-based ID for stability, similar to sliders 136 this.id = widgetConfig.id || getWidgetID?.(widgetConfig); 137 this.widgetConfig = widgetConfig; 138 } 139 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} 229 230// 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} 241 242// wire up @strudel/draw functions 243 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} 254 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});