1import { ref, pure } from '@strudel/core'; 2import { WidgetType, ViewPlugin, Decoration } from '@codemirror/view'; 3import { StateEffect } from '@codemirror/state'; 4 5// Global state storage for all widget types 6export let sliderValues = {}; 7 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; 161 162 // 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 }); 183 184 // 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); 190 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); 203 204/** 205 * Displays a slider widget to allow the user manipulate a value 206 * 207 * @name slider 208 * @tags external_io, visualization 209 * @param {number} value Initial value 210 * @param {number} min Minimum value - optional, defaults to 0 211 * @param {number} max Maximum value - optional, defaults to 1 212 * @param {number} step Step size - optional 213 */ 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}; 218// 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}; 223 224// 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}