jevstrudel.git / packages / codemirror / highlight.mjs
1import { RangeSetBuilder, StateEffect, StateField, Prec } from '@codemirror/state';
2import { Decoration, EditorView } from '@codemirror/view';
3import { lineHighlightExtension, lineHighlights } from './lineHighlights.mjs';
4
5export const setMiniLocations = StateEffect.define();
6export const showMiniLocations = StateEffect.define();
7export const displayMiniLocations = StateEffect.define();
8export const updateMiniLocations = (view, locations, range = null) => {
9  view.dispatch({ effects: setMiniLocations.of({ locations, range }) });
10};
11export const highlightMiniLocations = (view, atTime, haps) => {
12  view.dispatch({ effects: showMiniLocations.of({ atTime, haps }) });
13};
14
15const miniLocations = StateField.define({
16  create() {
17    return Decoration.none;
18  },
19  update(locations, tr) {
20    if (tr.docChanged) {
21      locations = locations.map(tr.changes);
22    }
23
24    for (let e of tr.effects) {
25      if (e.is(setMiniLocations)) {
26        //block-based eval case
27        if (e.value.range) {
28          const stateMiniLocations = getMiniLocationsFromDecorations(locations);
29
30          const normalized = e.value.locations
31            .filter(([from]) => from < tr.newDoc.length)
32            .map(([from, to]) => [from, Math.min(to, tr.newDoc.length)]);
33
34          const newIds = new Set(normalized.map((r) => r.join(':')));
35
36          const marks = normalized.map((range) => {
37            const id = range.join(':');
38            return Decoration.mark({
39              id,
40              // this green is only to verify that the decoration moves when the document is edited
41              // it will be removed later, so the mark is not visible by default
42              attributes: { style: `background-color: #00CA2880` },
43            }).range(...range); // -> Decoration
44          });
45
46          const previousMarks = stateMiniLocations
47            .filter(({ id }) => !newIds.has(id))
48            .map(({ from, to, id }) =>
49              Decoration.mark({
50                id,
51                attributes: { style: `background-color: #00CA2880` },
52              }).range(from, to),
53            );
54
55          locations = Decoration.set(previousMarks.concat(marks), true); // -> DecorationSet === RangeSet<Decoration>
56        } else {
57          // this is called on eval, with the mini locations obtained from the transpiler
58          // codemirror will automatically remap the marks when the document is edited
59          // create a mark for each mini location, adding the range to the spec to find it later
60          const marks = e.value.locations
61            .filter(([from]) => from < tr.newDoc.length)
62            .map(([from, to]) => [from, Math.min(to, tr.newDoc.length)])
63            .map(
64              (range) =>
65                Decoration.mark({
66                  id: range.join(':'),
67                  // this green is only to verify that the decoration moves when the document is edited
68                  // it will be removed later, so the mark is not visible by default
69                  attributes: { style: `background-color: #00CA2880` },
70                }).range(...range), // -> Decoration
71            );
72          locations = Decoration.set(marks, true); // -> DecorationSet === RangeSet<Decoration>
73        }
74      }
75    }
76
77    return locations;
78  },
79});
80
81const visibleMiniLocations = StateField.define({
82  create() {
83    return { atTime: 0, haps: new Map() };
84  },
85  update(visible, tr) {
86    for (let e of tr.effects) {
87      if (e.is(showMiniLocations)) {
88        // this is called every frame to show the locations that are currently active
89        // we can NOT create new marks because the context.locations haven't changed since eval time
90        // this is why we need to find a way to update the existing decorations, showing the ones that have an active range
91        const haps = new Map();
92        for (let hap of e.value.haps) {
93          if (!hap.context?.locations || !hap.whole) {
94            continue;
95          }
96          for (let { start, end } of hap.context.locations) {
97            let id = `${start}:${end}`;
98            if (!haps.has(id) || haps.get(id).whole.begin.lt(hap.whole.begin)) {
99              haps.set(id, hap);
100            }
101          }
102        }
103        visible = { atTime: e.value.atTime, haps };
104      }
105    }
106
107    return visible;
108  },
109});
110
111const displayMiniLocationsState = StateField.define({
112  create() {
113    return true; // default to showing miniLocations
114  },
115  update(display, tr) {
116    for (let e of tr.effects) {
117      if (e.is(displayMiniLocations)) {
118        display = e.value;
119      }
120    }
121    return display;
122  },
123});
124
125// // Derive the set of decorations from the miniLocations and visibleLocations
126const miniLocationHighlights = EditorView.decorations.compute(
127  [miniLocations, visibleMiniLocations, displayMiniLocationsState],
128  (state) => {
129    // Check if miniLocations display is disabled
130    const shouldDisplay = state.field(displayMiniLocationsState);
131    if (!shouldDisplay) {
132      return Decoration.none; // Return empty decorations if display is disabled
133    }
134
135    const iterator = state.field(miniLocations).iter();
136    const { haps } = state.field(visibleMiniLocations);
137    const builder = new RangeSetBuilder();
138
139    while (iterator.value) {
140      const {
141        from,
142        to,
143        value: {
144          spec: { id },
145        },
146      } = iterator;
147
148      if (haps.has(id)) {
149        const hap = haps.get(id);
150        const color = hap.value?.color ?? 'var(--foreground)';
151        const style = hap.value?.markcss || `outline: solid 2px ${color}`;
152        // Get explicit channels for color values
153        /* 
154    const swatch = document.createElement('div');
155    swatch.style.color = color;
156    document.body.appendChild(swatch);
157    let channels = getComputedStyle(swatch)
158      .color.match(/^rgba?\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})(?:,\s*(\d*(?:\.\d+)?))?\)$/)
159      .slice(1)
160      .map((c) => parseFloat(c || 1));
161    document.body.removeChild(swatch);
162
163    // Get percentage of event
164    const percent = 1 - (atTime - hap.whole.begin) / hap.whole.duration;
165    channels[3] *= percent;
166    */
167
168        builder.add(
169          from,
170          to,
171          Decoration.mark({
172            // attributes: { style: `outline: solid 2px rgba(${channels.join(', ')})` },
173            attributes: { style },
174          }),
175        );
176      }
177
178      iterator.next();
179    }
180
181    return builder.finish();
182  },
183);
184
185const getMiniLocationsFromDecorations = (decorations) => {
186  const iterator = decorations.iter();
187  const miniLocationsArray = [];
188  while (iterator.value) {
189    const {
190      from,
191      to,
192      value: {
193        spec: { id },
194      },
195    } = iterator;
196    miniLocationsArray.push({
197      from,
198      to,
199      id,
200    });
201    iterator.next();
202  }
203  return miniLocationsArray;
204};
205
206export const getMiniLocations = (state) => {
207  const decorations = state.field(miniLocations);
208  return getMiniLocationsFromDecorations(decorations);
209};
210
211export const getActiveMiniLocations = (state) => {
212  const miniLocations = getMiniLocations(state);
213  const { haps } = state.field(visibleMiniLocations);
214
215  const activeMiniLocations = miniLocations.filter((location) => haps.has(location.id));
216  return activeMiniLocations;
217};
218
219// jevstrudel: the same outlined locations, to tint their lines and show on
220// the minimap (lineHighlights.mjs), each in its hap's colour.
221const miniLocationLines = lineHighlights.compute([miniLocations, visibleMiniLocations, displayMiniLocationsState], (state) => {
222  if (!state.field(displayMiniLocationsState)) return [];
223  const { haps } = state.field(visibleMiniLocations);
224  const ranges = [];
225  for (const { from, to, id } of getMiniLocationsFromDecorations(state.field(miniLocations))) {
226    const hap = haps.get(id);
227    if (!hap) continue;
228    const color = typeof hap.value?.color === 'string' ? hap.value.color : null;
229    ranges.push({ from, to, color, at: Number(hap.whole.begin) });
230  }
231  return ranges;
232});
233
234export const highlightExtension = [
235  miniLocations,
236  visibleMiniLocations,
237  displayMiniLocationsState,
238  miniLocationHighlights,
239  miniLocationLines,
240  lineHighlightExtension,
241];
242
243export const isPatternHighlightingEnabled = (on, config) => {
244  // NOTE:
245  // Modified this function to always return the highlightExtension, and instead just toggle whether or not the miniLocations are displayed.
246  // This is because block based evaluation only updates regions of miniLocations, and those updates need to be kept track of constantly.
247  // The setTimeout was also removed because it conflicted with the range-specific updates required by
248  // block based evaluation.
249  // Not sure if this is the best approach, but for block based eval I can't think of a better way to do it.
250
251  if (config) {
252    // Toggle the display state for miniLocations
253    config.editor.dispatch({ effects: displayMiniLocations.of(on) });
254  }
255
256  return Prec.highest(highlightExtension);
257};