From aa094bf9309ccef568b1d34726aaf878437df802 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 29 Sep 2023 10:40:00 +0200 Subject: [PATCH] checkbox + number slider --- packages/codemirror/checkbox.mjs | 87 ++++++++++++ packages/codemirror/slider.mjs | 129 ++++++++++++++++++ packages/react/src/components/CodeMirror6.jsx | 4 +- 3 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 packages/codemirror/checkbox.mjs create mode 100644 packages/codemirror/slider.mjs diff --git a/packages/codemirror/checkbox.mjs b/packages/codemirror/checkbox.mjs new file mode 100644 index 00000000..279e2eb8 --- /dev/null +++ b/packages/codemirror/checkbox.mjs @@ -0,0 +1,87 @@ +import { WidgetType } from '@codemirror/view'; +import { ViewPlugin, Decoration } from '@codemirror/view'; +import { syntaxTree } from '@codemirror/language'; + +export class CheckboxWidget extends WidgetType { + constructor(checked) { + super(); + this.checked = checked; + } + + eq(other) { + return other.checked == this.checked; + } + + toDOM() { + let wrap = document.createElement('span'); + wrap.setAttribute('aria-hidden', 'true'); + wrap.className = 'cm-boolean-toggle'; + let box = wrap.appendChild(document.createElement('input')); + box.type = 'checkbox'; + box.checked = this.checked; + return wrap; + } + + ignoreEvent() { + return false; + } +} + +// EditorView +export function checkboxes(view) { + let widgets = []; + for (let { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter: (node) => { + if (node.name == 'BooleanLiteral') { + let isTrue = view.state.doc.sliceString(node.from, node.to) == 'true'; + let deco = Decoration.widget({ + widget: new CheckboxWidget(isTrue), + side: 1, + }); + widgets.push(deco.range(node.from)); + } + }, + }); + } + return Decoration.set(widgets); +} + +export const checkboxPlugin = ViewPlugin.fromClass( + class { + decorations; //: DecorationSet + + constructor(view /* : EditorView */) { + this.decorations = checkboxes(view); + } + + update(update /* : ViewUpdate */) { + if (update.docChanged || update.viewportChanged) this.decorations = checkboxes(update.view); + } + }, + { + decorations: (v) => v.decorations, + + eventHandlers: { + mousedown: (e, view) => { + let target = e.target; /* as HTMLElement */ + if (target.nodeName == 'INPUT' && target.parentElement.classList.contains('cm-boolean-toggle')) + return toggleBoolean(view, view.posAtDOM(target)); + }, + }, + }, +); + +function toggleBoolean(view /* : EditorView */, pos /* : number */) { + let before = view.state.doc.sliceString(Math.max(0, pos), pos + 5).trim(); + let change; + if (!['true', 'false'].includes(before)) { + return false; + } + let insert = before === 'true' ? 'false' : 'true'; + change = { from: pos, to: pos + before.length, insert }; + view.dispatch({ changes: change }); + return true; +} diff --git a/packages/codemirror/slider.mjs b/packages/codemirror/slider.mjs new file mode 100644 index 00000000..f03dba18 --- /dev/null +++ b/packages/codemirror/slider.mjs @@ -0,0 +1,129 @@ +import { WidgetType } from '@codemirror/view'; +import { ViewPlugin, Decoration } from '@codemirror/view'; +import { syntaxTree } from '@codemirror/language'; + +export class SliderWidget extends WidgetType { + constructor(value, min, max, from, to) { + super(); + this.value = value; + this.min = min; + this.max = max; + this.from = from; + this.to = to; + } + + eq(other) { + const isSame = other.value.toFixed(4) == this.value.toFixed(4); + return isSame; + } + + toDOM() { + let wrap = document.createElement('span'); + wrap.setAttribute('aria-hidden', 'true'); + wrap.className = 'cm-slider'; // inline-flex items-center + let slider = wrap.appendChild(document.createElement('input')); + slider.type = 'range'; + slider.min = this.min; + slider.max = this.max; + slider.step = (this.max - this.min) / 1000; + slider.value = this.value; + slider.from = this.from; + slider.to = this.to; + slider.className = 'w-16'; + return wrap; + } + + ignoreEvent() { + return false; + } +} + +// EditorView +export function sliders(view) { + let widgets = []; + for (let { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter: (node) => { + if (node.name == 'Number') { + let value = view.state.doc.sliceString(node.from, node.to); + value = Number(value); + /* let min = Math.min(0, value); + let max = Math.max(value, 1); */ + let min = 0; + let max = 10; + //console.log('from', node.from, 'to', node.to); + let deco = Decoration.widget({ + widget: new SliderWidget(value, min, max, node.from, node.to), + side: 1, + }); + widgets.push(deco.range(node.from)); + } + }, + }); + } + return Decoration.set(widgets); +} + +let draggedSlider, init; +export const sliderPlugin = ViewPlugin.fromClass( + class { + decorations; //: DecorationSet + + constructor(view /* : EditorView */) { + this.decorations = sliders(view); + } + + update(update /* : ViewUpdate */) { + if (update.docChanged || update.viewportChanged) { + !init && (this.decorations = sliders(update.view)); + //init = true; + } + } + }, + { + decorations: (v) => v.decorations, + + eventHandlers: { + mousedown: (e, view) => { + let target = e.target; /* as HTMLElement */ + if (target.nodeName == 'INPUT' && target.parentElement.classList.contains('cm-slider')) { + draggedSlider = target; + // remember offsetLeft / clientWidth, as they will vanish inside mousemove events for some reason + draggedSlider._offsetLeft = draggedSlider.offsetLeft; + draggedSlider._clientWidth = draggedSlider.clientWidth; + return updateSliderValue(view, e); + } + }, + mouseup: () => { + draggedSlider = undefined; + }, + mousemove: (e, view) => { + draggedSlider && updateSliderValue(view, e); + }, + }, + }, +); + +function updateSliderValue(view, e) { + const mouseX = e.clientX; + let progress = (mouseX - draggedSlider._offsetLeft) / draggedSlider._clientWidth; + progress = Math.max(Math.min(1, progress), 0); + let min = Number(draggedSlider.min); + let max = Number(draggedSlider.max); + const next = Number(progress * (max - min) + min); + let insert = next.toFixed(2); + let before = view.state.doc.sliceString(draggedSlider.from, draggedSlider.to).trim(); + before = Number(before).toFixed(4); + if (before === next) { + return false; + } + //console.log('before', before, '->', insert); + let change = { from: draggedSlider.from, to: draggedSlider.to, insert }; + draggedSlider.to = draggedSlider.from + insert.length; + //console.log('change', change); + view.dispatch({ changes: change }); + + return true; +} diff --git a/packages/react/src/components/CodeMirror6.jsx b/packages/react/src/components/CodeMirror6.jsx index 0f1b2274..f3c764e0 100644 --- a/packages/react/src/components/CodeMirror6.jsx +++ b/packages/react/src/components/CodeMirror6.jsx @@ -15,10 +15,12 @@ import { updateMiniLocations, } from '@strudel/codemirror'; import './style.css'; +import { checkboxPlugin } from '@strudel/codemirror/checkbox.mjs'; +import { sliderPlugin } from '@strudel/codemirror/slider.mjs'; export { flash, highlightMiniLocations, updateMiniLocations }; -const staticExtensions = [javascript(), flashField, highlightExtension]; +const staticExtensions = [javascript(), flashField, highlightExtension, checkboxPlugin, sliderPlugin]; export default function CodeMirror({ value,