From 63c23736ade95bf7c1d2073afd87ba4ad04d2d8b Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sun, 2 Jul 2023 18:33:44 +0200 Subject: [PATCH] wip: adaptive highlighting --- packages/react/src/components/CodeMirror6.jsx | 83 ++++++++++++++++++- packages/react/src/hooks/useHighlighting.mjs | 3 +- packages/react/src/index.js | 2 +- website/src/repl/Repl.jsx | 42 +++++++++- 4 files changed, 124 insertions(+), 6 deletions(-) diff --git a/packages/react/src/components/CodeMirror6.jsx b/packages/react/src/components/CodeMirror6.jsx index fe538019..e9734421 100644 --- a/packages/react/src/components/CodeMirror6.jsx +++ b/packages/react/src/components/CodeMirror6.jsx @@ -44,12 +44,89 @@ export const flash = (view) => { }, 200); }; +export const setMiniLocations = StateEffect.define(); +export const showMiniLocations = StateEffect.define(); +export const updateMiniLocations = (view, locations) => { + view.dispatch({ effects: setMiniLocations.of(locations) }); +}; +export const highlightMiniLocations = (view, haps) => { + view.dispatch({ effects: showMiniLocations.of(haps) }); +}; + +const miniLocations = StateField.define({ + create() { + return Decoration.none; + }, + update(locations, tr) { + locations = locations.map(tr.changes); + + for (let e of tr.effects) { + if (e.is(setMiniLocations)) { + // this is called on eval, with the mini locations obtained from the transpiler + // codemirror will automatically remap the marks when the document is edited + // create a mark for each mini location, adding the range to the spec to find it later + const marks = e.value.map( + (range) => + Decoration.mark({ + range, + // this green is only to verify that the decoration moves when the document is edited + // it will be removed later, so the mark is not visible by default + attributes: { style: `background-color: #00CA2880` }, + }), // -> Decoration + ); + // + const decorations = marks + .map((mark) => { + let { range } = mark.spec; + range = range.map((v) => Math.min(v, tr.newDoc.length)); + const [from, to] = range; + if (from < to) { + return mark.range(from, to); // -> Range + } + }) + .filter(Boolean); + locations = Decoration.set(decorations); // -> DecorationSet === RangeSet + } + if (e.is(showMiniLocations)) { + // this is called every frame to show the locations that are currently active + // we can NOT create new marks because the context.locations haven't changed since eval time + // this is why we need to find a way to update the existing decorations, showing the ones that have an active range + const visible = e.value + .map((hap) => hap.context.locations.map(({ start, end }) => `${start.offset}:${end.offset}`)) + .flat() + .filter((v, i, a) => a.indexOf(v) === i); + console.log('visible', visible); // e.g. [ "1:3", "8:9", "4:6" ] + + // TODO: iterate over "locations" variable, get access to underlying mark.spec.range + // for each mark that is visible, change color (later remove green color...) + // How to iterate over DecorationSet ??? + + /* console.log('iter', iter.value.spec.range); + while (iter.next().value) { + console.log('iter', iter.value); + } */ + /* locations = locations.update({ + filter: (from, to) => { + //console.log('filter', from, to); + // const id = `${from}:${to}`; + //return visible.includes(`${from}:${to}`); + return true; + }, + }); */ + } + } + return locations; + }, + provide: (f) => EditorView.decorations.from(f), +}); + export const setHighlights = StateEffect.define(); const highlightField = StateField.define({ create() { return Decoration.none; }, update(highlights, tr) { + highlights = highlights.map(tr.changes); try { for (let e of tr.effects) { if (e.is(setHighlights)) { @@ -88,13 +165,14 @@ const highlightField = StateField.define({ provide: (f) => EditorView.decorations.from(f), }); -const staticExtensions = [javascript(), highlightField, flashField]; +const staticExtensions = [javascript(), highlightField, flashField, miniLocations]; export default function CodeMirror({ value, onChange, onViewChanged, onSelectionChange, + onDocChange, theme, keybindings, isLineNumbersDisplayed, @@ -121,6 +199,9 @@ export default function CodeMirror({ const handleOnUpdate = useCallback( (viewUpdate) => { + if (viewUpdate.docChanged && onDocChange) { + onDocChange?.(viewUpdate); + } if (viewUpdate.selectionSet && onSelectionChange) { onSelectionChange?.(viewUpdate.state.selection); } diff --git a/packages/react/src/hooks/useHighlighting.mjs b/packages/react/src/hooks/useHighlighting.mjs index 26239871..f622bdd9 100644 --- a/packages/react/src/hooks/useHighlighting.mjs +++ b/packages/react/src/hooks/useHighlighting.mjs @@ -1,5 +1,5 @@ import { useEffect, useRef } from 'react'; -import { setHighlights } from '../components/CodeMirror6'; +import { setHighlights, highlightMiniLocations } from '../components/CodeMirror6'; const round = (x) => Math.round(x * 1000) / 1000; function useHighlighting({ view, pattern, active, getTime }) { @@ -21,6 +21,7 @@ function useHighlighting({ view, pattern, active, getTime }) { const haps = pattern.queryArc(...span).filter((hap) => hap.hasOnset()); highlights.current = highlights.current.concat(haps); // add potential new onsets view.dispatch({ effects: setHighlights.of({ haps: highlights.current }) }); // highlight all still active + new active haps + highlightMiniLocations(view, highlights.current); // <- new method, replaces above line when done } catch (err) { view.dispatch({ effects: setHighlights.of({ haps: [] }) }); } diff --git a/packages/react/src/index.js b/packages/react/src/index.js index f9eca1cd..f5bdbb35 100644 --- a/packages/react/src/index.js +++ b/packages/react/src/index.js @@ -1,6 +1,6 @@ // import 'tailwindcss/tailwind.css'; -export { default as CodeMirror, flash } from './components/CodeMirror6'; // !SSR +export { default as CodeMirror, flash, updateMiniLocations, highlightMiniLocations } from './components/CodeMirror6'; // !SSR export * from './components/MiniRepl'; // !SSR export { default as useHighlighting } from './hooks/useHighlighting'; // !SSR export { default as useStrudel } from './hooks/useStrudel'; // !SSR diff --git a/website/src/repl/Repl.jsx b/website/src/repl/Repl.jsx index 2030fad1..84babdef 100644 --- a/website/src/repl/Repl.jsx +++ b/website/src/repl/Repl.jsx @@ -5,7 +5,15 @@ This program is free software: you can redistribute it and/or modify it under th */ import { cleanupDraw, cleanupUi, controls, evalScope, getDrawContext, logger } from '@strudel.cycles/core'; -import { CodeMirror, cx, flash, useHighlighting, useStrudel, useKeydown } from '@strudel.cycles/react'; +import { + CodeMirror, + cx, + flash, + useHighlighting, + useStrudel, + useKeydown, + updateMiniLocations, +} from '@strudel.cycles/react'; import { getAudioContext, initAudioOnFirstClick, resetLoadedSounds, webaudioOutput } from '@strudel.cycles/webaudio'; import { createClient } from '@supabase/supabase-js'; import { nanoid } from 'nanoid'; @@ -101,12 +109,13 @@ const { code: randomTune, name } = getRandomTune(); export const ReplContext = createContext(null); +let init = false; // this is bad! only for testing! + export function Repl({ embedded = false }) { const isEmbedded = embedded || window.location !== window.parent.location; const [view, setView] = useState(); // codemirror view const [lastShared, setLastShared] = useState(); const [pending, setPending] = useState(true); - const { theme, keybindings, @@ -129,7 +138,9 @@ export function Repl({ embedded = false }) { cleanupDraw(); }, afterEval: ({ code, meta }) => { - console.log('miniLocations', meta.miniLocations); + console.log('miniLocations', meta.miniLocations, view); + // TODO: find a way to get hold of the codemirror view + // then call updateMiniLocations setPending(false); setLatestCode(code); window.location.hash = '#' + encodeURIComponent(btoa(code)); @@ -201,6 +212,30 @@ export function Repl({ embedded = false }) { // TODO: scroll to selected function in reference // console.log('selectino change', selection.ranges[0].from); }, []); + + const handleDocChanged = useCallback( + (v) => { + if (!init) { + // this is only for testing! try this pattern: + /* +stack( + s("bd"), + s("hh oh*<2 3>") +) + */ + updateMiniLocations(view, [ + [12, 14], + [23, 25], + [26, 28], + [30, 31], + [32, 33], + ]); + init = true; + } + }, + [view], + ); + const handleTogglePlay = async () => { await getAudioContext().resume(); // fixes no sound in ios webkit if (!started) { @@ -295,6 +330,7 @@ export function Repl({ embedded = false }) { onChange={handleChangeCode} onViewChanged={handleViewChanged} onSelectionChange={handleSelectionChange} + onDocChange={handleDocChanged} /> {error && (